<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>WiLine Edge Cloud — AI Tutorials</title>
        <link>https://development-wec.wiline.com/docs/tutorials/</link>
        <description>Tested, reproducible tutorials for self-hosting AI infrastructure on WiLine.</description>
        <lastBuildDate>Fri, 18 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[Give each MCP tool its own scope, and return a refusal the client can act on]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/</guid>
            <pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 6 gated the whole server with one scope, which meant the token that let an agent look up a customer also let it issue refunds. Splitting that per tool is easy. Making the refusal useful is not: the obvious implementation returns HTTP 200 with the error buried in the body, so a client has nothing to trigger step-up authorization on. Getting the 403 and WWW-Authenticate challenge the spec defines means leaving the framework's abstraction entirely — and the version that does it decodes the token a second time, unverified.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__authentik" src="data:image/png;base64,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" alt="authentik"><span class="tutorialHero__plus">+</span><img class="tutorialHero__mcp" src="data:image/png;base64,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" alt="Model Context Protocol"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">A scope per tool, and a refusal clients can act on</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/">Part 6</a> put <code>required_scopes=["mcp:invoke"]</code> on the JWT
verifier and called the server authorised. It is, in the sense that an unauthenticated caller
gets nothing. It is not, in the sense that matters: <code>mcp:invoke</code> opens every tool on the server.
The same token that lets an agent run <code>find_customer</code> lets it run <code>issue_refund</code>.</p>
<p>That is the whole gap. A read-only agent and a refund-issuing agent hold identical credentials,
and the only thing standing between "look up Maria" and "refund invoice 2" is that nobody asked.</p>
<p>This post closes it, badly first and then properly, because the badly is what most people ship
and the difference only shows up in what the client can <em>do</em> about a refusal.</p>
<p>Both versions refuse the same call — a read-only token asking for <code>issue_refund</code>. They differ in
<em>where</em> the scope is checked, and that one choice decides what the client gets back:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<p>Parts 5 and 6, specifically:</p>
<ul>
<li class="">Authentik issuing client-credentials tokens to an <code>agent-tools</code> provider
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/">part 5</a>)</li>
<li class="">A FastMCP server verifying those tokens against the JWKS endpoint
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/">part 6</a>)</li>
<li class=""><code>~/mcp-auth/token.sh</code>, which takes a scope string and returns an access token</li>
</ul>
<p>Part 6's server stays on <code>:8770</code> throughout. The two versions below run on <code>:8771</code> and <code>:8772</code>
so you can compare all three without stopping anything.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--two-new-scopes">Step 1 — Two new scopes<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#step-1--two-new-scopes" class="hash-link" aria-label="Direct link to Step 1 — Two new scopes" title="Direct link to Step 1 — Two new scopes" translate="no">​</a></h2>
<p>Scopes are Property Mappings in Authentik. <strong>Customization → Property Mappings → New Property
Mapping → Scope Mapping</strong>, twice:</p>
<table><thead><tr><th>Name</th><th>Scope name</th><th>Description</th></tr></thead><tbody><tr><td><code>office-read</code></td><td><code>office:read</code></td><td>Look up customers and invoices</td></tr><tr><td><code>office-refund</code></td><td><code>office:refund</code></td><td>Issue refunds</td></tr></tbody></table>
<p><span class="zoomImage__wrap"><img alt="Two new scope mappings alongside the existing gateway-invoke and mcp-invoke" src="https://development-wec.wiline.com/docs/assets/images/h10-scope-mappings-c80d52648d4347ac31467ecd7bfbc924.png" width="1901" height="831" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Creating them is not enough. A provider will only issue a scope it has been given, so open
<strong>Applications → Providers → agent-tools → Edit</strong> and move both into <em>Selected Scopes</em>.</p>
<p><span class="zoomImage__wrap"><img alt="The agent-tools provider with mcp-invoke, office-read and office-refund selected" src="https://development-wec.wiline.com/docs/assets/images/h10-provider-scopes-845343ec44444c00ad1c5931086ff83c.png" width="1902" height="944" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Note the line under the picker: <em>"Select which scopes can be used by the client. The client still
has to specify the scope to access the data."</em> Both halves matter. Selecting a scope here does not
put it in every token — it permits the client to ask. A client that asks for nothing gets nothing,
which is why every <code>token.sh</code> call below passes an explicit scope string.</p>
<p>Confirm the token actually carries what you asked for:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/mcp-auth/token.sh </span><span class="token string" style="color:#e3116c">"mcp:invoke office:read"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cut</span><span class="token plain"> -d. </span><span class="token parameter variable" style="color:#36acaa">-f2</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> base64 </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq .scope</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"mcp:invoke office:read"</span><br></div></code></pre></div></div>
<p>If that comes back without <code>office:read</code>, the scope is not on the provider — fix that before
writing any server code, or you will spend an hour debugging enforcement that is working
correctly on a token that was never scoped.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--the-obvious-implementation">Step 2 — The obvious implementation<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#step-2--the-obvious-implementation" class="hash-link" aria-label="Direct link to Step 2 — The obvious implementation" title="Direct link to Step 2 — The obvious implementation" translate="no">​</a></h2>
<p>Keep <code>required_scopes=["mcp:invoke"]</code> on the verifier as the price of admission, then ask per
tool whether the caller holds what <em>that</em> operation needs. FastMCP exposes the verified token
through <code>get_access_token()</code>, so a decorator can read the claims the verifier already checked:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">exceptions </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> ToolError</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">server</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dependencies </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> get_access_token</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">requires</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">scope</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">decorate</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token decorator annotation punctuation" style="color:#393A34">@functools</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">wraps</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">wrapper</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">args</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">**</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            token </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> get_access_token</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            held </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">set</span><span class="token punctuation" style="color:#393A34">(</span><span class="token builtin">getattr</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">token</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"scopes"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> scope </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> held</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token keyword" style="color:#00009f">raise</span><span class="token plain"> ToolError</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    </span><span class="token string-interpolation string" style="color:#e3116c">f'insufficient_scope: this tool requires "</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">scope</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"; '</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    </span><span class="token string-interpolation string" style="color:#e3116c">f'token carries </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation builtin">sorted</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">held</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation"> </span><span class="token string-interpolation interpolation keyword" style="color:#00009f">or</span><span class="token string-interpolation interpolation"> </span><span class="token string-interpolation interpolation string" style="color:#e3116c">"nothing"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> fn</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">args</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">**</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> wrapper</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> decorate</span><br></div></code></pre></div></div>
<p>Then one line per tool:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@requires</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"office:read"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">find_customer</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">query</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@requires</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"office:refund"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">issue_refund</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">invoice_id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><br></div></code></pre></div></div>
<p>Run it on <code>:8771</code> and drive both tools with both tokens:</p>
<p><span class="zoomImage__wrap"><img alt="Two tokens, two tools: each token allows one and refuses the other" src="https://development-wec.wiline.com/docs/assets/images/h10-per-tool-enforcement-36365bd959bc324dd447cfdbe681525c.png" width="845" height="564" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">--- token: mcp:invoke + office:read ---</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  find_customer  : ALLOWED -&gt; [{'id': 1, 'name': 'Maria Alvarez', ...}]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  issue_refund   : REFUSED -&gt; insufficient_scope: this tool requires "office:refund"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">--- token: mcp:invoke + office:refund ---</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  find_customer  : REFUSED -&gt; insufficient_scope: this tool requires "office:read"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  issue_refund   : ALLOWED -&gt; Refunded 80.00 on invoice 2.</span><br></div></code></pre></div></div>
<p>That is real enforcement. The refund token cannot read, the read token cannot refund, and the
refusal names the missing scope. For a lot of deployments this is where you stop.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--why-that-refusal-is-not-good-enough">Step 3 — Why that refusal is not good enough<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#step-3--why-that-refusal-is-not-good-enough" class="hash-link" aria-label="Direct link to Step 3 — Why that refusal is not good enough" title="Direct link to Step 3 — Why that refusal is not good enough" translate="no">​</a></h2>
<p>Watch the HTTP layer rather than the client library.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">TOK</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/mcp-auth/token.sh </span><span class="token variable string" style="color:#e3116c">"mcp:invoke office:read"</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8771/mcp </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$TOK</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Accept: application/json, text/event-stream"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"issue_refund","arguments":{"invoice_id":2}}}'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The tool-body check returns HTTP 200 with the refusal inside the body" src="https://development-wec.wiline.com/docs/assets/images/h10-200-no-challenge-ec64c16f2444a9ed7bcacaed0d917f3e.png" width="827" height="346" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">HTTP/1.1 200 OK</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">content-type: text/event-stream</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"insufficient_scope: this tool</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">requires \"office:refund\"; token carries ['mcp:invoke', 'office:read']","type":"text"},</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">"isError":true}}</span><br></div></code></pre></div></div>
<p><strong>HTTP 200.</strong> The request succeeded; the <em>tool</em> declined. That is correct JSON-RPC semantics and
it is useless to an OAuth client.</p>
<p>An OAuth client that wants to step up — go back to the authorization server and ask for
<code>office:refund</code> — is watching for a <code>401</code> or <code>403</code> with a <code>WWW-Authenticate</code> header telling it
what to request. It does not parse English out of a tool result. So the refusal is legible to a
human reading logs and invisible to the machinery designed to handle exactly this case.</p>
<p>It also arrives late. The tool body runs after routing, after session setup, after the server has
committed to a successful response. By then there is no status line left to change.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--the-challenge-the-spec-actually-wants">Step 4 — The challenge the spec actually wants<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#step-4--the-challenge-the-spec-actually-wants" class="hash-link" aria-label="Direct link to Step 4 — The challenge the spec actually wants" title="Direct link to Step 4 — The challenge the spec actually wants" translate="no">​</a></h2>
<p>Getting a <code>403</code> means checking scopes <em>before</em> the JSON-RPC layer answers, which means ASGI
middleware wrapping the FastMCP app:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">ScopeChallengeMiddleware</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">__init__</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> app</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">app </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> app</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">__call__</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> scope</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> receive</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> send</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> scope</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> scope</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"method"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"POST"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">app</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">scope</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> receive</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> send</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic"># Buffer the body so we can inspect it and still pass it on.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        chunks</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> more </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">while</span><span class="token plain"> more</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            msg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> receive</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            chunks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">append</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"body"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">b""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            more </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"more_body"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">False</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        body </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">b""</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">chunks</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        needed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">try</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            rpc </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">body</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> rpc</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"method"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tools/call"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                needed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> TOOL_SCOPES</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">rpc</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"params"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"name"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">except</span><span class="token plain"> Exception</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">pass</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> needed </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> needed </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> token_scopes</span><span class="token punctuation" style="color:#393A34">(</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">scope</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"headers"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            challenge </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string-interpolation string" style="color:#e3116c">f'Bearer error="insufficient_scope", scope="</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">needed</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">", '</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string-interpolation string" style="color:#e3116c">f'resource_metadata="</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">RESOURCE_METADATA</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">", '</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string-interpolation string" style="color:#e3116c">f'error_description="This operation requires the </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">needed</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> scope"'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> send</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http.response.start"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"status"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">403</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                        </span><span class="token string" style="color:#e3116c">"headers"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">b"content-type"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">b"application/json"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                    </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">b"www-authenticate"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> challenge</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">encode</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> send</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http.response.body"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                        </span><span class="token string" style="color:#e3116c">"body"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dumps</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"error"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"insufficient_scope"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                            </span><span class="token string" style="color:#e3116c">"scope"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> needed</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">encode</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic"># Replay the buffered body downstream.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        replayed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">False</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">replay</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">nonlocal</span><span class="token plain"> replayed</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> replayed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                replayed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http.request"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"body"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> body</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"more_body"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">False</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> receive</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">app</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">scope</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> replay</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> send</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">app </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ScopeChallengeMiddleware</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">mcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">http_app</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">stateless_http</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Same request, against <code>:8772</code>:</p>
<p><span class="zoomImage__wrap"><img alt="The middleware returns 403 with a WWW-Authenticate challenge naming the missing scope" src="https://development-wec.wiline.com/docs/assets/images/h10-403-challenge-2a8d3ff52be96195ff042471605c0c4e.png" width="827" height="247" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">HTTP/1.1 403 Forbidden</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">www-authenticate: Bearer error="insufficient_scope", scope="office:refund",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  resource_metadata="http://127.0.0.1:8772/.well-known/oauth-protected-resource",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  error_description="This operation requires the office:refund scope"</span><br></div></code></pre></div></div>
<p>Now a client has something to act on: the status says refused, <code>scope=</code> says what to ask for, and
<code>resource_metadata</code> says where to look up how. That is step-up authorization as a protocol rather
than as a log message.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--what-it-cost">Step 5 — What it cost<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#step-5--what-it-cost" class="hash-link" aria-label="Direct link to Step 5 — What it cost" title="Direct link to Step 5 — What it cost" translate="no">​</a></h2>
<p>The middleware works. It is also worse code than the decorator, in three specific ways, and
pretending otherwise would be dishonest.</p>
<p><strong>It does not know what a tool is.</strong> ASGI middleware sees bytes and headers. To find out which
tool is being called it parses the JSON-RPC envelope itself and looks the name up in a table it
has to maintain:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">TOOL_SCOPES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"find_customer"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"office:read"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"open_invoices"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"office:read"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"issue_refund"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">  </span><span class="token string" style="color:#e3116c">"office:refund"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>That table is a second source of truth. Add a tool and forget the entry and it is unprotected —
silently, because the middleware just passes through anything it does not recognise. The
decorator could not have that bug: the requirement sat on the function.</p>
<p><strong>It decodes the token unverified.</strong> The middleware runs <em>upstream</em> of the verifier, so the
verified claims do not exist yet. It splits the JWT and base64-decodes the payload without
checking the signature:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">payload </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> auth</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">claims </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">base64</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">urlsafe_b64decode</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">payload </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"="</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">*</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">-</span><span class="token builtin">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">payload</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">%</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">4</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>This is not the hole it looks like — the verifier still runs downstream and still rejects a
forged token, so nothing reaches a tool on a bad signature. But the <em>scope decision</em> is made on
unauthenticated bytes, and the only reason that is survivable is the second check behind it. It
is a wart, not a vulnerability, and it is the kind of thing worth writing down before someone
later removes the "redundant" verifier.</p>
<p><strong>It buffers every request body.</strong> To read the envelope and still pass it downstream, the
middleware drains <code>receive()</code> into memory and replays it. Fine for JSON-RPC calls; think harder
before putting this in front of large uploads.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="insufficient_scope-on-a-tool-you-did-grant"><code>insufficient_scope</code> on a tool you did grant<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#insufficient_scope-on-a-tool-you-did-grant" class="hash-link" aria-label="Direct link to insufficient_scope-on-a-tool-you-did-grant" title="Direct link to insufficient_scope-on-a-tool-you-did-grant" translate="no">​</a></h3>
<p>Check the token, not the server:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/mcp-auth/token.sh </span><span class="token string" style="color:#e3116c">"mcp:invoke office:refund"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cut</span><span class="token plain"> -d. </span><span class="token parameter variable" style="color:#36acaa">-f2</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> base64 </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq .scope</span><br></div></code></pre></div></div>
<p>If <code>office:refund</code> is missing, the scope exists as a Property Mapping but was never moved into
the provider's <em>Selected Scopes</em>. Authentik silently drops scopes a client is not permitted to
request rather than erroring, so the token comes back valid and short.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-middleware-never-fires">The middleware never fires<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#the-middleware-never-fires" class="hash-link" aria-label="Direct link to The middleware never fires" title="Direct link to The middleware never fires" translate="no">​</a></h3>
<p>It only inspects <code>POST</code>. MCP clients open a <code>GET</code> for the event stream first, and that request
carries no JSON-RPC envelope — if you are watching the wrong request you will conclude the
middleware is dead. Confirm with the <code>curl</code> above, which is a single <code>POST</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="every-tool-suddenly-returns-403">Every tool suddenly returns 403<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#every-tool-suddenly-returns-403" class="hash-link" aria-label="Direct link to Every tool suddenly returns 403" title="Direct link to Every tool suddenly returns 403" translate="no">​</a></h3>
<p><code>TOOL_SCOPES</code> is keyed by the tool's registered name, which is the <em>function</em> name, not the
decorated label. Rename a function and the table stops matching. The pass-through case is the
dangerous direction (unprotected), but a typo in the table produces this one.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-403-body-is-empty-in-some-clients">The 403 body is empty in some clients<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#the-403-body-is-empty-in-some-clients" class="hash-link" aria-label="Direct link to The 403 body is empty in some clients" title="Direct link to The 403 body is empty in some clients" translate="no">​</a></h3>
<p>The challenge lives in the <code>WWW-Authenticate</code> <strong>header</strong>. Clients that only log response bodies
will show you <code>{"error":"insufficient_scope"}</code> and nothing about which scope. Use <code>curl -i</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p><strong>It bought</strong> genuine per-tool authorisation: two tokens that differ by one scope, each able to
run exactly one of two tools, proven at the HTTP layer rather than asserted. And with the
middleware, a refusal a client can programmatically recover from.</p>
<p><strong>It did not buy</strong> a clean design. Both implementations are compromises pointing opposite ways:</p>
<table><thead><tr><th></th><th>tool decorator (<code>:8771</code>)</th><th>ASGI middleware (<code>:8772</code>)</th></tr></thead><tbody><tr><td>Scope requirement lives</td><td>on the function</td><td>in a separate table</td></tr><tr><td>Reads verified claims</td><td>yes</td><td>no — decodes unverified, verifier runs after</td></tr><tr><td>Refusal</td><td>HTTP 200, <code>isError: true</code></td><td>HTTP 403 + <code>WWW-Authenticate</code></td></tr><tr><td>Client can step up</td><td>no</td><td>yes</td></tr><tr><td>New tool unprotected by default</td><td>no</td><td><strong>yes</strong></td></tr></tbody></table>
<p>The honest summary is that the correct protocol behaviour requires leaving the abstraction the
framework gives you, and the ergonomic version cannot produce it. If your clients do not
implement step-up — and most agent clients today do not — the decorator is the better trade.
If they do, you pay for it with a table you must remember to update.</p>
<p><strong>It also did not buy</strong> authorisation that survives the tool doing something else. <code>issue_refund</code>
is gated on <code>office:refund</code>; nothing stops a future <code>find_customer</code> from being edited to write.
Scopes gate entry, not behaviour — which is the same boundary
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">part 4</a> drew around policy-in-the-tool.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The obvious remaining gap is that both versions trust the token's scope list and nothing else.
Neither asks <em>who</em> the caller is or <em>what</em> they are acting on — a token with <code>office:refund</code>
refunds any invoice, for any customer, for any amount. That is object-level authorisation, and
it does not live in OAuth scopes at all.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>A scope per tool, and a refusal clients can act on</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://modelcontextprotocol.io/specification/basic/authorization" target="_blank" rel="noopener noreferrer" class="">MCP specification — authorization</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc6750#section-3" target="_blank" rel="noopener noreferrer" class="">RFC 6750 §3 — the <code>WWW-Authenticate</code> challenge</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc9728" target="_blank" rel="noopener noreferrer" class="">RFC 9728 — OAuth 2.0 Protected Resource Metadata</a></li>
<li class=""><a href="https://docs.goauthentik.io/docs/providers/property-mappings/" target="_blank" rel="noopener noreferrer" class="">Authentik — property mappings and scopes</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>mcp</category>
            <category>oauth2</category>
            <category>authentik</category>
            <category>jwt</category>
            <category>scopes</category>
            <category>agents</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Sandbox the code your agent writes, and prove every limit actually applied]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[OWASP names running model-generated code through exec or eval as its own vulnerability, tells you to treat the model as untrusted — and says nothing about how to contain it. So we built the containment: no network, 256 MB, 64 processes, read-only disk, no capabilities. Every one of those held. The CPU limit was refused outright, and eight busy loops took 605% of the host. The cause is one line of systemd configuration nobody mentions, and the fix needed no reboot despite the documentation insisting otherwise.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">9</span><span class="skillTracker__skill" data-state="current">Run the model's own code without trusting it</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Every agent tutorial in this series so far has given a model <em>tools</em> — functions you wrote,
with arguments you defined. This post is about the other thing agents do, which is write code
and then run it.</p>
<p>That is a different risk, and the difference is worth being precise about. A tool call is the
model choosing from a menu you control. Executing generated code is the model handing you
something nobody has ever read, which you then run on your machine.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, kernel 5.15, systemd 249.
Docker rootless <strong>29.8.1</strong> from <a class="" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/">Part 8</a>, running
alongside the system daemon. Test image is <code>python:3.12-alpine</code>. The host had 20 containers
running throughout.</p><p>Every number and error message below is from that run.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-needs-its-own-post">Why this needs its own post<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#why-this-needs-its-own-post" class="hash-link" aria-label="Direct link to Why this needs its own post" title="Direct link to Why this needs its own post" translate="no">​</a></h2>
<p>The industry has a name for this. OWASP's
<a href="https://genai.owasp.org/llmrisk/llm052025-improper-output-handling/" target="_blank" rel="noopener noreferrer" class="">LLM05:2025 Improper Output Handling</a>
lists it as a vulnerability example, in plain words:</p>
<blockquote>
<p>LLM output is entered directly into a system shell or similar function such as exec or eval,
resulting in remote code execution.</p>
</blockquote>
<p>And its advice on how to think about the model is exactly right:</p>
<blockquote>
<p>Treat the model as any other user, adopting a zero-trust approach</p>
</blockquote>
<p>Here is what makes that page worth reading closely, though. It tells you to treat model output
as untrusted, it names code execution as the consequence, and then its prevention section talks
entirely about <em>validating text</em> — output encoding, parameterised queries, content security
policies, monitoring.</p>
<p>Search that page for <strong>sandbox</strong>, <strong>container</strong>, <strong>least privilege</strong> or <strong>isolation</strong> and you
get zero hits. Not few. None.</p>
<p>That is not a criticism of OWASP; the risk is about output handling, and validation is the right
answer for output that becomes HTML or SQL. But when the output is a program you are about to
run, validation stops being possible. You cannot regex your way to knowing whether a hundred
lines of Python are safe.</p>
<p>So the guidance names the problem and stops one layer above the fix. This post is that layer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-obvious-version-actually-allows">What the obvious version actually allows<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#what-the-obvious-version-actually-allows" class="hash-link" aria-label="Direct link to What the obvious version actually allows" title="Direct link to What the obvious version actually allows" translate="no">​</a></h2>
<p>The naive implementation is what most people ship first, and it looks responsible — the code
runs in a container, after all:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> python:3.12-alpine python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"&lt;the model's code&gt;"</span><br></div></code></pre></div></div>
<p>Let's ask that container what it can reach.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> python:3.12-alpine python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import os, urllib.request</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('uid inside     :', os.getuid())</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('can see host / :', sorted(os.listdir('/'))[:10])</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">try:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    r = urllib.request.urlopen('https://api.github.com', timeout=5)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print('internet       : REACHED', r.status)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">except Exception as e:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print('internet       : blocked', type(e).__name__)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> python:3.12-alpine </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">echo "memory limit : $(cat /sys/fs/cgroup/memory.max)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">echo "pids limit   : $(cat /sys/fs/cgroup/pids.max)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">echo "cpus visible : $(nproc)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">echo "writable fs  : $(touch /proc-test 2&gt;/dev/null &amp;&amp; echo yes || echo no)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="A container running model code reports uid 0, full internet access returning HTTP 200, unlimited memory, a pids limit of 19136, eight visible CPUs and a writable filesystem" src="https://development-wec.wiline.com/docs/assets/images/h9-naive-sandbox-06b5b3907235d86115ef158babda8df0.png" width="781" height="561" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">internet       : REACHED 200</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">memory limit : max</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">pids limit   : 19136</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">cpus visible : 8</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">writable fs  : yes</span><br></div></code></pre></div></div>
<p>Read that as a list of what generated code is allowed to do on your machine:</p>
<ul>
<li class=""><strong>Reach the internet.</strong> Anything it finds, it can send somewhere.</li>
<li class=""><strong>Take all the memory.</strong> <code>max</code> means no limit. On this host that is 15 GB, and the kernel
will start killing other things to provide it.</li>
<li class=""><strong>Start 19,136 processes.</strong> Enough to make the host unusable.</li>
<li class=""><strong>Use all eight cores.</strong></li>
<li class=""><strong>Write anywhere in the container</strong>, including filling the disk.</li>
</ul>
<p>Part 8 moved the daemon off root, so this code can no longer take over the <em>host</em>. That was
worth doing and it does not help here at all. None of the five items above involves being root.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="closing-it-down">Closing it down<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#closing-it-down" class="hash-link" aria-label="Direct link to Closing it down" title="Direct link to Closing it down" translate="no">​</a></h2>
<p>Docker has flags for every one of these. They are unglamorous and they are the entire fix:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">--network</span><span class="token plain"> none </span><span class="token parameter variable" style="color:#36acaa">--memory</span><span class="token plain"> 256m --pids-limit </span><span class="token number" style="color:#36acaa">64</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--cpus</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --read-only </span><span class="token parameter variable" style="color:#36acaa">--tmpfs</span><span class="token plain"> /tmp:size</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">16m </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --cap-drop</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ALL --security-opt no-new-privileges </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  python:3.12-alpine </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">command</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<p>In plain terms:</p>
<table><thead><tr><th>Flag</th><th>What it stops</th></tr></thead><tbody><tr><td><code>--network none</code></td><td>No network at all — nothing can be sent anywhere</td></tr><tr><td><code>--memory 256m</code></td><td>Uses more than 256 MB and the kernel kills it, not your other services</td></tr><tr><td><code>--pids-limit 64</code></td><td>A fork bomb hits 64 and stops</td></tr><tr><td><code>--cpus 0.5</code></td><td>An infinite loop gets half a core</td></tr><tr><td><code>--read-only</code></td><td>Nothing can be written to the container's filesystem</td></tr><tr><td><code>--tmpfs /tmp:size=16m</code></td><td>Except a small scratch area, capped, in memory</td></tr><tr><td><code>--cap-drop=ALL</code></td><td>No kernel privileges — no mounting, no raw sockets, nothing</td></tr><tr><td><code>--security-opt no-new-privileges</code></td><td>Nothing inside can gain more privileges than it started with</td></tr></tbody></table>
<p>Run it, and it fails before the container even starts:</p>
<p><span class="zoomImage__wrap"><img alt="Docker refuses the run with the error NanoCPUs can not be set, as your kernel does not support CPU CFS scheduler or the cgroup is not mounted" src="https://development-wec.wiline.com/docs/assets/images/h9-cpus-refused-0a8c67fa115c08d21d53054814b86f30.png" width="994" height="344" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">docker: Error response from daemon: NanoCPUs can not be set, as your kernel does not</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">support CPU CFS scheduler or the cgroup is not mounted</span><br></div></code></pre></div></div>
<p>Hold onto that message, because it is misleading. The kernel supports CPU limits perfectly
well — this host runs 20 other containers with them available. We will come back to what is
actually wrong.</p>
<p>Drop <code>--cpus</code> and everything else applies:</p>
<p><span class="zoomImage__wrap"><img alt="The hardened container reports a 256 MiB memory limit, a pids limit of 64, a read-only filesystem, a writable tmp, and network access blocked with URLError" src="https://development-wec.wiline.com/docs/assets/images/h9-hardened-holds-e5ed1f26eadb61b1c097d1438eae1520.png" width="751" height="396" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">memory limit : 268435456        &lt;- exactly 256 MiB</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">pids limit   : 64</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">cpus visible : 8</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">writable fs  : no</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">tmp writable : yes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">internet     : blocked URLError</span><br></div></code></pre></div></div>
<p>Five of six controls working. And <code>cpus visible: 8</code>, because we were not allowed to set one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-the-gap-is-real">Proving the gap is real<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#proving-the-gap-is-real" class="hash-link" aria-label="Direct link to Proving the gap is real" title="Direct link to Proving the gap is real" translate="no">​</a></h2>
<p>A missing CPU limit sounds mild next to "unlimited memory". It is not, because an infinite loop
is the single most likely thing generated code does by accident.</p>
<p>This runs eight busy loops inside the otherwise fully locked-down sandbox, for eight seconds:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> burn </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">--network</span><span class="token plain"> none </span><span class="token parameter variable" style="color:#36acaa">--memory</span><span class="token plain"> 256m --pids-limit </span><span class="token number" style="color:#36acaa">64</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --read-only </span><span class="token parameter variable" style="color:#36acaa">--tmpfs</span><span class="token plain"> /tmp:size</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">16m </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --cap-drop</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ALL --security-opt no-new-privileges </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  python:3.12-alpine </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'for i in $(seq 8); do (while :; do :; done) &amp; done; sleep 8'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless stats --no-stream burn</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Docker stats showing the burn container consuming 605.19 percent CPU while memory sits at 1.23 MiB of 256 MiB, network IO at zero and 9 processes" src="https://development-wec.wiline.com/docs/assets/images/h9-burn-605-a829703726df3283acc0afa12d391696.png" width="678" height="208" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NAME   CPU %     MEM USAGE / LIMIT   NET I/O   PIDS</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">burn   605.19%   1.23MiB / 256MiB    0B / 0B   9</span><br></div></code></pre></div></div>
<p><strong>605% of a CPU.</strong> Six of the host's eight cores, taken by code inside a sandbox where memory is
capped at 256 MB (it used 1.23), processes are capped at 64 (it used 9), and the network is
completely blocked.</p>
<p>Every control we set is working. The one we could not set is the one being abused.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-actually-wrong">What is actually wrong<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#what-is-actually-wrong" class="hash-link" aria-label="Direct link to What is actually wrong" title="Direct link to What is actually wrong" translate="no">​</a></h2>
<p>The error message blamed the kernel. The kernel is fine. The real answer is one file.</p>
<p>When Docker runs rootless, it does not get to manage resources directly — it can only use what
the system has handed to your user account. On modern Linux that handover is called <strong>cgroup
delegation</strong>, and systemd decides what gets delegated.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> /sys/fs/cgroup/user.slice/user-1000.slice/cgroup.controllers</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">memory pids</span><br></div></code></pre></div></div>
<p>Two things. That is the whole explanation:</p>
<!-- -->
<p>The daemon knew all along. It said so at startup, ten times, in lines nobody reads:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">journalctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> docker.service </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"no .* support"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The rootless daemon startup log listing ten warnings including no cpu cfs quota support, no cpuset support, no io.weight support and no io.max support" src="https://development-wec.wiline.com/docs/assets/images/h9-delegation-root-cause-801bd67d214cce22c58da71540cddebb.png" width="1233" height="260" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No cpu cfs quota support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No cpu cfs period support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No cpu shares support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No cpuset support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No io.weight support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No io.max (rbps) support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div></code></pre></div></div>
<p>This also explains something <a class="" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/">Part 8</a> reported as a
plain limitation of rootless mode: disk throughput limits not working. Same cause. Not a
property of rootless — a property of what systemd delegated.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-this-is-documented">Where this is documented<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#where-this-is-documented" class="hash-link" aria-label="Direct link to Where this is documented" title="Direct link to Where this is documented" translate="no">​</a></h3>
<p>Not in Docker's rootless documentation. I searched the page: <strong>zero</strong> occurrences of
"delegate". The guidance lives upstream, on the
<a href="https://rootlesscontaine.rs/getting-started/common/cgroup2/" target="_blank" rel="noopener noreferrer" class="">rootless containers site</a>, which
states it directly:</p>
<blockquote>
<p>By default, a non-root user can only get memory controller and pids controller to be delegated.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-fix">The fix<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#the-fix" class="hash-link" aria-label="Direct link to The fix" title="Direct link to The fix" translate="no">​</a></h2>
<p>One drop-in file telling systemd to hand over more:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> /etc/systemd/system/user@.service.d</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">EOF</span><span class="token string bash punctuation" style="color:#393A34"> </span><span class="token string bash punctuation operator" style="color:#393A34">|</span><span class="token string bash punctuation" style="color:#393A34"> </span><span class="token string bash punctuation function" style="color:#d73a49">sudo</span><span class="token string bash punctuation" style="color:#393A34"> </span><span class="token string bash punctuation function" style="color:#d73a49">tee</span><span class="token string bash punctuation" style="color:#393A34"> /etc/systemd/system/user@.service.d/delegate.conf</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">[Service]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">Delegate=cpu cpuset io memory pids</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> systemctl daemon-reload</span><br></div></code></pre></div></div>
<p>The same page warns:</p>
<blockquote>
<p>After changing the systemd configuration, you need to re-login or reboot the host. Rebooting
the host is recommended.</p>
</blockquote>
<p>On systemd 249 that turned out not to be needed. After <code>daemon-reload</code> alone:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> /sys/fs/cgroup/user.slice/user-1000.slice/cgroup.controllers</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">cpuset cpu io memory pids</span><br></div></code></pre></div></div>
<p>The daemon still needs restarting, because it reads its capabilities once at startup. Restart
<strong>only the rootless daemon</strong> — not your whole user session, which on this host would also have
taken down an unrelated service:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> restart docker.service</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">journalctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> docker.service </span><span class="token parameter variable" style="color:#36acaa">--since</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"-1min"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-ci</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"no cpu\|no io"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The delegate.conf drop-in being written, followed by a restart of the rootless docker service and a warning count of zero" src="https://development-wec.wiline.com/docs/assets/images/h9-delegate-fix-1f7414c6361fa1fc13be57138940d1f5.png" width="646" height="206" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">0</span><br></div></code></pre></div></div>
<p>Zero warnings, where there were ten.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-same-eight-loops-again">The same eight loops, again<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#the-same-eight-loops-again" class="hash-link" aria-label="Direct link to The same eight loops, again" title="Direct link to The same eight loops, again" translate="no">​</a></h2>
<p>Identical command to before. One flag added:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> burn2 </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">--network</span><span class="token plain"> none </span><span class="token parameter variable" style="color:#36acaa">--memory</span><span class="token plain"> 256m --pids-limit </span><span class="token number" style="color:#36acaa">64</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--cpus</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --read-only </span><span class="token parameter variable" style="color:#36acaa">--tmpfs</span><span class="token plain"> /tmp:size</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">16m </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --cap-drop</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ALL --security-opt no-new-privileges </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  python:3.12-alpine </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'for i in $(seq 8); do (while :; do :; done) &amp; done; sleep 8'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless stats --no-stream burn2</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Docker stats showing the burn2 container held at 52.82 percent CPU with the same memory, network and process figures as before" src="https://development-wec.wiline.com/docs/assets/images/h9-burn-capped-7dff3d60a38ee6b89cb8667301faea0f.png" width="665" height="204" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NAME    CPU %    MEM USAGE / LIMIT    NET I/O   PIDS</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">burn2   52.82%   1.242MiB / 256MiB    0B / 0B   9</span><br></div></code></pre></div></div>
<p><strong>605.19% → 52.82%.</strong> Same eight infinite loops, same image, same everything. Half a core, as
asked.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-should-take-from-this">What you should take from this<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#what-you-should-take-from-this" class="hash-link" aria-label="Direct link to What you should take from this" title="Direct link to What you should take from this" translate="no">​</a></h2>
<p><strong>A container is not a sandbox until you say what it may use.</strong> The default is generous in every
dimension that matters, and nothing warns you, because an unrestricted container is a completely
normal thing to run.</p>
<p><strong>Check your limits are applied, rather than assuming.</strong> The whole of this post exists because a
flag that was silently unavailable looked identical to a flag that was working. <code>docker stats</code>
under a deliberate load takes a minute and tells you the truth.</p>
<p><strong>Error messages point at the wrong layer.</strong> "your kernel does not support CPU CFS scheduler"
described a kernel that supports it fine. The real cause was a systemd default, two layers away,
documented on a different website.</p>
<p>And the honest limit: this sandbox contains <em>resources</em>. It does not make the code safe. It
cannot tell a useful script from a hostile one, and a program that does something harmful within
256 MB, half a core and no network will run exactly as written. Containment buys you a blast
radius, not a judgement.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Run the model's own code without trusting it</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://genai.owasp.org/llmrisk/llm052025-improper-output-handling/" target="_blank" rel="noopener noreferrer" class="">OWASP — LLM05:2025 Improper Output Handling</a></li>
<li class=""><a href="https://docs.docker.com/engine/containers/resource_constraints/" target="_blank" rel="noopener noreferrer" class="">Docker — Runtime options with Memory, CPUs, and GPUs</a></li>
<li class=""><a href="https://rootlesscontaine.rs/getting-started/common/cgroup2/" target="_blank" rel="noopener noreferrer" class="">Rootless Containers — cgroup v2 delegation</a></li>
<li class=""><a href="https://docs.kernel.org/admin-guide/cgroup-v2.html" target="_blank" rel="noopener noreferrer" class="">Linux kernel — Control Group v2</a></li>
<li class=""><a href="https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html" target="_blank" rel="noopener noreferrer" class=""><code>systemd.resource-control(5)</code> — <code>Delegate=</code></a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>docker</category>
            <category>sandbox</category>
            <category>agents</category>
            <category>code-execution</category>
            <category>cgroups</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Move the Docker daemon off root so a container escape lands on an ordinary user]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/</guid>
            <pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[If you can run docker without sudo, you can already read every file on the machine. Not through a bug — that is how Docker is built. One command proves it. For a host running agents that execute generated code, that is the whole attack, and Part 2's non-root containers do not touch it. This moves the daemon itself to an unprivileged account, with the UID arithmetic you can check, four places the install walks you into a wall on Ubuntu, and what stops working afterwards.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">8</span><span class="skillTracker__skill" data-state="current">Move the daemon off root</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Here is the part nobody says out loud when they tell you to add yourself to the <code>docker</code> group
so you can stop typing <code>sudo</code>.</p>
<p>That group is root. Not "close to root", not "root for Docker things". If you can run a
container, you can read, change or delete any file on the machine — including the password
file, including other people's home directories, including the files the administrator
deliberately kept away from you.</p>
<p>No exploit required. It is one command, and it takes about four seconds.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, kernel 5.15. System daemon is
Ubuntu's <code>docker.io</code> <strong>29.1.3</strong>; the rootless daemon installed here is <strong>29.8.1</strong> with
<code>rootlesskit</code> 3.1.0. Test image is <code>alpine:3.20</code>. The host was running 20 containers under the
system daemon throughout, and none of them stopped.</p><p>Every command output and timing below is from that run.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-it-on-your-own-machine">Proving it on your own machine<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#proving-it-on-your-own-machine" class="hash-link" aria-label="Direct link to Proving it on your own machine" title="Direct link to Proving it on your own machine" translate="no">​</a></h2>
<p>Run this as a normal user. There is no <code>sudo</code> anywhere in it.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> /:/host alpine:3.20 </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'touch /host/root/rootless-demo &amp;&amp; echo WROTE as uid $(id -u)'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ls</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-l</span><span class="token plain"> /root/rootless-demo</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="An unprivileged user runs a container that creates a file inside /root, and the host listing confirms the file is owned by root" src="https://development-wec.wiline.com/docs/assets/images/h8-docker-group-is-root-8007f44ffccf255e2de1e18d9bf1ca00.png" width="1158" height="178" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WROTE as uid 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-rw-r--r-- 1 root root 0 Sep 16 20:58 /root/rootless-demo</span><br></div></code></pre></div></div>
<p>A user who cannot so much as list <code>/root</code> just created a file in it, owned by root.</p>
<p>Nothing malfunctioned. That is the design. The daemon runs as root, you asked it to mount the
whole filesystem, and it did — because a root process is allowed to. The container's "root" is
the host's root. They are the same thing.</p>
<p>Docker documents this plainly, and it is worth reading their wording rather than mine. From
<a href="https://docs.docker.com/engine/security/#docker-daemon-attack-surface" target="_blank" rel="noopener noreferrer" class="">Docker daemon attack surface</a>:</p>
<blockquote>
<p>only trusted users should be allowed to control your Docker daemon</p>
</blockquote>
<p>and, on the same page:</p>
<blockquote>
<p>Docker allows you to share a directory between the Docker host and a guest container; and it
allows you to do so without limiting the access rights of the container.</p>
</blockquote>
<p>That is not a warning about a flaw. It is a description of the feature we just used.</p>
<p>Clean it up before moving on:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> /root/rootless-demo</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-is-worse-on-a-host-running-agents">Why this is worse on a host running agents<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#why-this-is-worse-on-a-host-running-agents" class="hash-link" aria-label="Direct link to Why this is worse on a host running agents" title="Direct link to Why this is worse on a host running agents" translate="no">​</a></h3>
<p>An AI agent that executes code it generated is, in security terms, remote code execution that
you installed on purpose. That is not a criticism of agents — it is what they are for.</p>
<p>So the question stops being <em>"could an attacker run code here?"</em> — something already does,
constantly — and becomes <em>"what can that code reach?"</em></p>
<p>If it can reach the Docker socket, the answer is everything, via the command above. Your API
keys. Your SSH keys. The database volume. All of it, regardless of how carefully the agent's
own container was configured.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-part-2-did-and-did-not-fix">What Part 2 did and did not fix<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#what-part-2-did-and-did-not-fix" class="hash-link" aria-label="Direct link to What Part 2 did and did not fix" title="Direct link to What Part 2 did and did not fix" translate="no">​</a></h3>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/">Part 2</a> found two of six Langfuse containers
running as root and fixed them with <code>user: "999:999"</code>. That was worth doing and it is still
worth doing.</p>
<p>But it changes who the process is <strong>inside</strong> the container. The daemon that starts it is still
root, and the socket is still a door to the whole machine. Part 2 hardened the passenger. This
post goes after the driver.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-rootless-mode-actually-does">What rootless mode actually does<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#what-rootless-mode-actually-does" class="hash-link" aria-label="Direct link to What rootless mode actually does" title="Direct link to What rootless mode actually does" translate="no">​</a></h2>
<p>The daemon stops running as root and runs as you.</p>
<p>Containers still think they have root — software inside them keeps working normally. But that
"root" is now translated, by the kernel, into an ordinary unprivileged account that owns
nothing important. Escape the container and you are not the administrator. You are <code>ubuntu</code>.</p>
<p>The translation uses a block of user IDs the system set aside for you. Here is the whole idea
in one picture — the same container, under each daemon:</p>
<!-- -->
<p>The left half is why the first command worked. The right half is what we are about to build,
and the arithmetic in it is something you can verify yourself in a minute.</p>
<p>This translation is a Linux kernel feature called a
<a href="https://man7.org/linux/man-pages/man7/user_namespaces.7.html" target="_blank" rel="noopener noreferrer" class="">user namespace</a>, not a Docker
invention — Docker is just asking the kernel for one. The block of IDs it maps into is declared
in <a href="https://man7.org/linux/man-pages/man5/subuid.5.html" target="_blank" rel="noopener noreferrer" class=""><code>/etc/subuid</code></a>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'^ubuntu:'</span><span class="token plain"> /etc/subuid /etc/subgid</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">/etc/subuid:ubuntu:100000:65536</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/etc/subgid:ubuntu:100000:65536</span><br></div></code></pre></div></div>
<p>65,536 IDs starting at 100000, yours alone. Hold onto that number — the mapping becomes
checkable arithmetic in a moment.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="installing-it-four-walls">Installing it: four walls<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#installing-it-four-walls" class="hash-link" aria-label="Direct link to Installing it: four walls" title="Direct link to Installing it: four walls" translate="no">​</a></h2>
<p>The documentation assumes a setup this machine does not have. Here is each wall in the order
you hit it.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="wall-1--the-package-does-not-exist">Wall 1 — the package does not exist<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#wall-1--the-package-does-not-exist" class="hash-link" aria-label="Direct link to Wall 1 — the package does not exist" title="Direct link to Wall 1 — the package does not exist" translate="no">​</a></h3>
<p>Every guide opens with the same line. On Ubuntu's Docker:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">apt-get</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> docker-ce-rootless-extras</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">dpkg </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> docker.io </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-iE</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'rootless|rootlesskit'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="apt reports it cannot locate package docker-ce-rootless-extras, and listing the docker.io package contents for rootless files returns nothing" src="https://development-wec.wiline.com/docs/assets/images/h8-no-rootless-package-4540e2d1cdbee668a23f37364afdc535.png" width="1190" height="246" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">E: Unable to locate package docker-ce-rootless-extras</span><br></div></code></pre></div></div>
<p>That package belongs to Docker's own repository. This host runs Ubuntu's <code>docker.io</code>, and the
second command returns <strong>nothing at all</strong> — the distribution package ships no rootless tooling
whatsoever.</p>
<p>That matters because it forces a choice. You can replace <code>docker.io</code> with Docker CE, which
means swapping out the daemon currently running your containers. Or you can install a second,
user-owned daemon beside the first. With 20 services running, the second option is the only
sane one, and it is what the rest of this post does.</p>
<p>What you do need from apt is the ID-mapping helpers:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">apt-get</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> uidmap</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>If apt offers to restart services</div><div class="admonitionContent_BuS1"><p>On Ubuntu 22.04 this may trigger a "Daemons using outdated libraries" prompt with
<code>containerd.service</code> and your database pre-selected. <code>uidmap</code> does not need any of them
restarted. Deselect them, or cancel the prompt — the install still completes.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="wall-2--the-installer-refuses-to-run">Wall 2 — the installer refuses to run<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#wall-2--the-installer-refuses-to-run" class="hash-link" aria-label="Direct link to Wall 2 — the installer refuses to run" title="Direct link to Wall 2 — the installer refuses to run" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://get.docker.com/rootless </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain"># Installing stable version 29.8.1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Aborting because rootful Docker is running and accessible.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Set FORCE_ROOTLESS_INSTALL=1 to ignore.</span><br></div></code></pre></div></div>
<p>The guard assumes you are <em>migrating</em> — that a running root daemon means you are about to
make a mess. Running both side by side on purpose is never mentioned, and the way to proceed
appears only inside the error text.</p>
<p>Note the version it names: <strong>29.8.1</strong>, while the system daemon is <strong>29.1.3</strong>. You will end up
with two different Docker versions on one host. That is expected here, and worth knowing.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://get.docker.com/rootless </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">FORCE_ROOTLESS_INSTALL</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="wall-3--it-moves-your-cli-without-telling-you">Wall 3 — it moves your CLI without telling you<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#wall-3--it-moves-your-cli-without-telling-you" class="hash-link" aria-label="Direct link to Wall 3 — it moves your CLI without telling you" title="Direct link to Wall 3 — it moves your CLI without telling you" translate="no">​</a></h3>
<p>Read the last lines of the installer output:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[INFO] Creating CLI context "rootless"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">[INFO] Using CLI context "rootless"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Current context is now "rootless"</span><br></div></code></pre></div></div>
<p>Now ask Docker what is running:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> context </span><span class="token function" style="color:#d73a49">ls</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> default </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-q</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">wc</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-l</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker ps lists no containers at all, docker context ls shows the star on rootless, and querying the default context reports 20 running containers" src="https://development-wec.wiline.com/docs/assets/images/h8-context-hijack-4d422c845ec7d88ee2b2f5a5ffbed4af.png" width="1182" height="330" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">NAME         DESCRIPTION                              DOCKER ENDPOINT                     ERROR</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">default      Current DOCKER_HOST based configuration  unix:///var/run/docker.sock</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">rootless *   Rootless mode                            unix:///run/user/1000/docker.sock</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">20</span><br></div></code></pre></div></div>
<p>An empty list, while twenty containers run perfectly well. Nothing broke and nothing warned
you — the CLI is answering a different question than the one you think you asked, because the
installer repointed it on the way out.</p>
<p>Put it back, and opt in per command instead:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> context use default          </span><span class="token comment" style="color:#999988;font-style:italic"># everyday work, unchanged</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain">               </span><span class="token comment" style="color:#999988;font-style:italic"># explicit when you want the new one</span><br></div></code></pre></div></div>
<p>Start the daemon and confirm what you have:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable environment constant" style="color:#36acaa">XDG_RUNTIME_DIR</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">/run/user/</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">id</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-u</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> start docker.service</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless info </span><span class="token parameter variable" style="color:#36acaa">--format</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.ServerVersion}} {{.SecurityOptions}}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">29.8.1 [name=seccomp,profile=builtin name=rootless name=cgroupns]</span><br></div></code></pre></div></div>
<p><code>name=rootless</code> is the daemon confirming what it is.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>The PATH advice is only half needed</div><div class="admonitionContent_BuS1"><p>The installer tells you to add <code>~/bin</code> to your <code>PATH</code>. For day-to-day use you do not have to:
the system <code>docker</code> client talks to the rootless daemon perfectly well through the context, and
every command in this post was run that way. You need the PATH only for the setup and uninstall
scripts — and without it they fail with <code>dockerd-rootless.sh needs to be present under $PATH</code>,
which tells you nothing about what is actually wrong.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="checking-that-the-mapping-is-real">Checking that the mapping is real<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#checking-that-the-mapping-is-real" class="hash-link" aria-label="Direct link to Checking that the mapping is real" title="Direct link to Checking that the mapping is real" translate="no">​</a></h2>
<p>Ask a container who it is, then look at that same process from outside:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> alpine:3.20 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> u0 alpine:3.20 </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">60</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">user</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">,uid</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">,cmd</span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-C</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'sleep 60'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> u1000 </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1000</span><span class="token plain">:1000 alpine:3.20 </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">120</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">uid</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">,cmd</span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-C</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'sleep 120'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The container reports uid 0 root; the host shows that process owned by ubuntu uid 1000; a container running as uid 1000 appears on the host as uid 100999" src="https://development-wec.wiline.com/docs/assets/images/h8-uid-mapping-d53136aff71ace37ef98f708404f45d5.png" width="1186" height="648" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">uid=0(root) gid=0(root) groups=0(root),...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ubuntu    1000 sleep 60</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">100999 sleep 120</span><br></div></code></pre></div></div>
<p>Read those three lines together, because they are the entire mechanism:</p>
<ul>
<li class="">The container believes it is root.</li>
<li class="">The host says that process belongs to <code>ubuntu</code>.</li>
<li class="">A container running as ID 1000 shows up on the host as <strong>100999</strong>.</li>
</ul>
<p>That last number is <code>100000 + 1000 - 1</code> — the start of your reserved block, offset by the
container's own ID, because container root already took the first slot by mapping to you.</p>
<p>Which gives you a test rather than a promise. Work it out from your own <code>/etc/subuid</code> line and
see whether the number matches. <strong>If you instead see plain <code>1000</code>, the translation is not
happening</strong> and something is wrong with the setup.</p>
<p>Clean up:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless </span><span class="token function" style="color:#d73a49">rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> u0 u1000</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-same-command-the-other-daemon">The same command, the other daemon<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#the-same-command-the-other-daemon" class="hash-link" aria-label="Direct link to The same command, the other daemon" title="Direct link to The same command, the other daemon" translate="no">​</a></h2>
<p>Back to the command this post opened with. Same image, same mount of the entire filesystem,
same flags. Only the daemon differs:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> /:/host alpine:3.20 </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'touch /host/root/rootless-demo || echo DENIED'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The identical command that previously wrote into /root now fails with permission denied under the rootless daemon" src="https://development-wec.wiline.com/docs/assets/images/h8-denied-e38f44dfdd6d58bf3f217438f3a584a0.png" width="1186" height="372" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">touch: /host/root/rootless-demo: Permission denied</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DENIED</span><br></div></code></pre></div></div>
<p>The container still believes it is root. The kernel disagrees, because the process behind it
belongs to <code>ubuntu</code>, and <code>ubuntu</code> cannot write to <code>/root</code>.</p>
<p>That is the post, in two outputs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-costs-you">What it costs you<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#what-it-costs-you" class="hash-link" aria-label="Direct link to What it costs you" title="Direct link to What it costs you" translate="no">​</a></h2>
<p>Four things, all measured here rather than quoted.</p>
<p><strong>Containers do not start noticeably slower.</strong> Three consecutive runs:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">i</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> /usr/bin/time </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"run+exit: %es"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> alpine:3.20 </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">80</span><span class="token plain">:80 alpine:3.20 </span><span class="token boolean" style="color:#36acaa">true</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Three container runs completing in 1.19, 0.96 and 0.97 seconds, followed by the privileged port error refusing to expose port 80" src="https://development-wec.wiline.com/docs/assets/images/h8-costs-03291fa4bb34544ae9eff02c34a5fa7a.png" width="1190" height="546" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">run+exit: 1.19s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">run+exit: 0.96s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">run+exit: 0.97s</span><br></div></code></pre></div></div>
<p><strong>Ports below 1024 are refused</strong>, and unusually, the error tells you every way out:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">cannot expose privileged port 80, you can add</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024),</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">or set CAP_NET_BIND_SERVICE on rootlesskit binary,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">or choose a larger port number (&gt;= 1024)</span><br></div></code></pre></div></div>
<p><code>-p 18080:80</code> binds without complaint. Behind a reverse proxy this rarely matters.</p>
<p><strong>Disk throughput limits stop working.</strong> The daemon says so at startup, and it is easy to miss:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No io.max (rbps) support</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: No io.max (wbps) support</span><br></div></code></pre></div></div>
<p>So <code>--device-read-bps</code> and <code>--device-write-bps</code> silently do nothing. If you were relying on
those to stop one container starving the others, rootless takes that away.</p>
<p><strong>Capability dropping still works</strong>, which matters because these are often treated as
alternatives:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> --cap-drop</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ALL alpine:3.20 </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'echo cap-drop ok'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">cap-drop ok</span><br></div></code></pre></div></div>
<p>They stack. Keep using <code>user:</code> and <code>--cap-drop=ALL</code> — each layer assumes the one before it
failed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="shared-folders-behave-differently">Shared folders behave differently<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#shared-folders-behave-differently" class="hash-link" aria-label="Direct link to Shared folders behave differently" title="Direct link to Shared folders behave differently" translate="no">​</a></h2>
<p>This is the part that catches people moving a real service across. Host permissions apply as
<em>you</em>, not as root:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">D</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">/tmp/rl-vol</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$D</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> hello </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$D</span><span class="token plain">/mine.txt</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"echo secret &gt; </span><span class="token string variable" style="color:#36acaa">$D</span><span class="token string" style="color:#e3116c">/theirs.txt; chmod 600 </span><span class="token string variable" style="color:#36acaa">$D</span><span class="token string" style="color:#e3116c">/theirs.txt"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$D</span><span class="token plain">:/data alpine:3.20 </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">'cat /data/mine.txt; cat /data/theirs.txt 2&gt;&amp;1 || echo DENIED'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> rootless run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$D</span><span class="token plain">:/data alpine:3.20 </span><span class="token function" style="color:#d73a49">stat</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'%u:%g %n'</span><span class="token plain"> /data/mine.txt</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The container reads the user-owned file, is denied on the root-owned file, and reports the user-owned file as belonging to 0:0 inside the container" src="https://development-wec.wiline.com/docs/assets/images/h8-volume-permissions-b16a91d894e4363082f108e17d3593e6.png" width="1196" height="374" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hello</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">cat: can't open '/data/theirs.txt': Permission denied</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DENIED</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">0:0 /data/mine.txt</span><br></div></code></pre></div></div>
<p>Your own file reads fine. The root-owned one is refused — even though the container thinks it
is root, because the kernel knows better. And your file appears inside the container as owned
by <code>0:0</code>, the same translation running in reverse.</p>
<p>Worth internalising before you move a database over and spend an hour wondering why it cannot
open its own data directory.</p>
<p>One more: the rootless daemon keeps a <strong>separate image store</strong> at <code>~/.local/share/docker</code>.
Images your system daemon already has are invisible to it, and everything gets pulled again. On
a tight disk, budget for it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wall-4--removing-it-refuses-too">Wall 4 — removing it refuses too<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#wall-4--removing-it-refuses-too" class="hash-link" aria-label="Direct link to Wall 4 — removing it refuses too" title="Direct link to Wall 4 — removing it refuses too" translate="no">​</a></h2>
<p>For symmetry, the uninstall has the same guard:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable environment constant" style="color:#36acaa">PATH</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">/home/ubuntu/bin:</span><span class="token environment constant" style="color:#36acaa">$PATH</span><span class="token plain"> ~/bin/dockerd-rootless-setuptool.sh uninstall</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The uninstall script aborts because rootful Docker is running and accessible, instructing the user to set --force" src="https://development-wec.wiline.com/docs/assets/images/h8-uninstall-refuses-9d0ed4f387ceb4f76df893f91156189a.png" width="1188" height="142" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[ERROR] Aborting because rootful Docker (/var/run/docker.sock) is running and accessible.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Set --force to ignore.</span><br></div></code></pre></div></div>
<p>Add <code>--force</code> when you genuinely want it gone.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-does-and-does-not-buy-you">What this does and does not buy you<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#what-this-does-and-does-not-buy-you" class="hash-link" aria-label="Direct link to What this does and does not buy you" title="Direct link to What this does and does not buy you" translate="no">​</a></h2>
<p>It changes the worst case from <em>"an attacker owns the machine"</em> to <em>"an attacker owns this
user account"</em>. On a host where every service runs as <code>ubuntu</code>, that is a large move and not a
complete one — your SSH keys, your <code>.env</code> files and your source code all still sit inside that
account.</p>
<p>So the honest summary: rootless raises the floor. It does not make the room safe. Narrowing
what the account itself can reach is the next rung, and that is where system-call filtering and
image supply chain come in.</p>
<p>But the specific thing that was true at the top of this post — that anyone able to run a
container could take the whole machine — is no longer true, and you can prove it in one
command.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Move the daemon off root</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.docker.com/engine/security/rootless/" target="_blank" rel="noopener noreferrer" class="">Docker — Run the daemon as a non-root user (rootless mode)</a></li>
<li class=""><a href="https://docs.docker.com/engine/security/userns-remap/" target="_blank" rel="noopener noreferrer" class="">Docker — Isolate containers with a user namespace</a></li>
<li class=""><a href="https://github.com/rootless-containers/rootlesskit" target="_blank" rel="noopener noreferrer" class="">rootlesskit</a></li>
<li class=""><a href="https://man7.org/linux/man-pages/man5/subuid.5.html" target="_blank" rel="noopener noreferrer" class=""><code>subuid(5)</code></a> and <a href="https://man7.org/linux/man-pages/man7/user_namespaces.7.html" target="_blank" rel="noopener noreferrer" class=""><code>user_namespaces(7)</code></a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>docker</category>
            <category>rootless</category>
            <category>containers</category>
            <category>privilege</category>
            <category>agents</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Firewall an agent container so it can reach one API and nothing else]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/</guid>
            <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Every part of this series so far has controlled what an agent may call. None of it controls where an agent may go. OWASP's mitigations for Excessive Agency are three rules about tools and say nothing about the network. This puts a default-deny egress policy in front of one container, allows exactly one API, and proves the boundary with a lookup that succeeds and a packet that dies. Includes the conntrack rule everyone tells you to add and this one doesn't need, and a measurement that was wrong by three orders of magnitude.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">7</span><span class="skillTracker__skill" data-state="current">Where the agent can go, not just what it can call</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">Agent orchestration part 4</a> split a toolbox so the
scheduler could not issue refunds. <a class="" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/">Part 5</a>
gave the agent an identity at the gateway. <a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/">Part 6</a> put a JWT
verifier in front of the tools server so it refuses anonymous callers.</p>
<p>Every one of those controls <strong>what the agent may call</strong>. Not one of them controls <strong>where the
agent may go</strong>.</p>
<p>That distinction is the whole of this post. Exfiltration does not need a tool. It needs a
socket.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, kernel 5.15. Docker
<code>29.1.3</code> with firewall backend <code>iptables</code> — which on this distribution is <code>iptables-nft</code>
v1.8.7, a shim writing into nftables. Sandbox container is <code>alpine:3.20</code>. UFW is active.</p><p>Every status code, error string and timing in this post is from the run described. Plain
HTTP on a private LAN where the LAN appears, as throughout this series.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-directions-and-only-one-is-usually-guarded">Two directions, and only one is usually guarded<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#two-directions-and-only-one-is-usually-guarded" class="hash-link" aria-label="Direct link to Two directions, and only one is usually guarded" title="Direct link to Two directions, and only one is usually guarded" translate="no">​</a></h2>
<p>A firewall decides which packets may cross a boundary. Most of the time people mean it in one
direction — <strong>ingress</strong>, who out there may reach <em>in</em> to your service. That is what publishing
a port does, and what <a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">part 1 of this series</a>
was about.</p>
<p><strong>Egress is the other direction</strong>: where your service may reach <em>out</em> to. Which addresses it
may open a connection to, and which it may not.</p>
<p>Hardly anyone configures egress, and for an ordinary web application that is a reasonable
choice. It talks to its database and a payment provider, it does what its code says, and its
code does not change between deployments.</p>
<p>An agent is not that. An agent decides what to do at runtime, from text it has just read — an
email, a support ticket, a web page, a document somebody else wrote. If an attacker controls
that text, they influence what the agent does next. And the thing you do not want it doing next
is <strong>exfiltration</strong>: sending your data somewhere it should not go.</p>
<p>Exfiltration needs no special tool. Any process that can open a socket can send anything it can
read. Which is why the direction nobody guards is the one that matters here.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-standards-actually-say">What the standards actually say<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#what-the-standards-actually-say" class="hash-link" aria-label="Direct link to What the standards actually say" title="Direct link to What the standards actually say" translate="no">​</a></h2>
<p>The leading reference for this class of problem is OWASP's <strong>LLM06:2025 Excessive Agency</strong>.
Its prevention section lists, in order: <em>minimize extensions</em>, <em>minimize extension
functionality</em>, <em>avoid open-ended extensions</em>, and further items about permissions and human
approval.</p>
<p>Read the page looking for the network and it is not there. Not "egress", not "firewall", not
"network", not "isolation", not "sandbox" — the words do not appear in the entry at all.</p>
<p><span class="zoomImage__wrap"><img alt="The OWASP LLM06 Excessive Agency page showing its prevention and mitigation strategies, all concerning tools and extensions" src="https://development-wec.wiline.com/docs/assets/images/h7-owasp-mitigations-d1025852bd1958d91bfe4304dd74c563.png" width="2551" height="1376" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Every mitigation on that list operates on <strong>tools</strong>. Remove the extension, narrow the
extension, avoid the open-ended extension. All good advice. None of it changes a single byte
of what a compromised process can do with a TCP socket.</p>
<p>There is an argument that this is a gap rather than an oversight, and it is being made inside
OWASP's own repository. An <a href="https://github.com/OWASP/www-project-top-10-for-large-language-model-applications/issues/802" target="_blank" rel="noopener noreferrer" class="">open issue proposing a runtime-enforcement mapping</a>
for the Agentic Top 10 states it plainly:</p>
<blockquote>
<p>The enforcement pipeline assumes agents cannot reach tools without passing through the
proxy. If tools are directly reachable, runtime enforcement collapses.</p>
</blockquote>
<p>and concludes that <em>"Network-level controls (e.g., Kubernetes NetworkPolicy, Docker network
isolation) are required to close this gap in production."</em></p>
<p>Note what that is: an <strong>open issue</strong>, filed by a contributor, with its mapping marked
<em>(Proposed)</em>. It is not the standard. It is someone arguing the standard should say this.</p>
<p>And the reason this matters is not theoretical. <strong>EchoLeak</strong> (<a href="https://nvd.nist.gov/vuln/detail/CVE-2025-32711" target="_blank" rel="noopener noreferrer" class="">CVE-2025-32711</a>)
is a zero-click indirect prompt injection against Microsoft 365 Copilot, recorded in NVD as
<em>"Ai command injection in M365 Copilot allows an unauthorized attacker to disclose information
over a network."</em> Over a network. The tool list was never the thing that failed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">Docker on Linux with the <code>iptables</code> firewall backend — the default. Check with
<code>docker info | grep -i "Firewall Backend"</code></li>
<li class=""><code>sudo</code>, and a willingness to write firewall rules on a box you can still reach</li>
<li class="">Nothing from the earlier parts. This one stands alone.</li>
</ul>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Read this before you touch DOCKER-USER</div><div class="admonitionContent_BuS1"><p><code>DOCKER-USER</code> is real firewall state on your host. A rule that matches more than you intended
will take services offline. Every rule in this post is scoped with <code>-i br-agent</code>, an interface
that belongs to one purpose-built network — which is exactly why we name that bridge by hand
in step 1 instead of letting Docker generate one.</p><p>If you are working over SSH, none of these rules touch the <code>INPUT</code> chain, so your session is
not at risk. Confirm that for yourself before you believe it.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--prove-the-gap">Step 1 — Prove the gap<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#step-1--prove-the-gap" class="hash-link" aria-label="Direct link to Step 1 — Prove the gap" title="Direct link to Step 1 — Prove the gap" translate="no">​</a></h2>
<p>A network, with the bridge interface named explicitly. Docker documents the option as
<em>"Interface name to use when creating the Linux bridge"</em>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> network create </span><span class="token parameter variable" style="color:#36acaa">--driver</span><span class="token plain"> bridge </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">com.docker.network.bridge.name</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">br-agent agent-egress</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">ip</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-br</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">link</span><span class="token plain"> show br-agent</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">br-agent         DOWN           8a:77:65:54:9b:0d &lt;NO-CARRIER,BROADCAST,MULTICAST,UP&gt;</span><br></div></code></pre></div></div>
<p><strong>DOWN</strong>, because nothing is plugged into it yet. A Docker network is an interface that does
not come up until a container attaches, and a rule matching <code>-i br-agent</code> on an idle network
silently matches nothing. Worth knowing before you spend an hour debugging a policy that was
never exercised.</p>
<p>The sandbox — pinned, not <code>latest</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> agent-sandbox </span><span class="token parameter variable" style="color:#36acaa">--network</span><span class="token plain"> agent-egress alpine:3.20 </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> infinity</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox apk </span><span class="token function" style="color:#d73a49">add</span><span class="token plain"> --no-cache </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> bind-tools</span><br></div></code></pre></div></div>
<p>Now four destinations. Note what is <em>not</em> in this container: no agent, no framework, no MCP
client, no tools. Tool scoping is not being bypassed here — there is nothing to bypass.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'inference API   HTTP %{http_code}\n'</span><span class="token plain"> https://inference.wiline.com/v1/models</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'arbitrary host  HTTP %{http_code}\n'</span><span class="token plain"> https://example.com</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'authentik LAN   HTTP %{http_code}\n'</span><span class="token plain"> http://10.80.4.212:9100/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'other network   exit=%{exitcode} %{errormsg}\n'</span><span class="token plain"> http://172.21.0.2:5432/</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">inference API   HTTP 401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">arbitrary host  HTTP 200</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">authentik LAN   HTTP 302</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">other network   exit=28 Connection timed out after 5002 milliseconds</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Four probes from the sandbox container: the inference API answering 401, example.com answering 200, authentik answering 302, and a cross-network connection timing out" src="https://development-wec.wiline.com/docs/assets/images/h7-before-unrestricted-527fc904745f99b3fdab1b9d08035428.png" width="1234" height="444" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The <code>401</code> is the useful one to understand. No API key was sent, so the request never reaches
anything that could charge for it — but a <code>401</code> is an HTTP response, which means the TCP
handshake completed, TLS negotiated, and a real server answered. Reachability, proven, for
free.</p>
<p>The fourth line is the one that keeps this honest. That is a Postgres container on a different
Docker network, and it <strong>times out</strong> — <a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">part 1 of this series</a>
established that separate bridge networks genuinely isolate, and this confirms it.</p>
<p>So the shape of the hole is precise rather than vague:</p>
<table><thead><tr><th>Destination</th><th>Result</th><th></th></tr></thead><tbody><tr><td>Another Docker network</td><td>timeout</td><td>Docker already handles this</td></tr><tr><td>Your own LAN, including the identity provider</td><td><code>302</code></td><td>open</td></tr><tr><td>The open internet</td><td><code>200</code></td><td>open</td></tr><tr><td>The one API it actually needs</td><td><code>401</code></td><td>open</td></tr></tbody></table>
<p>Docker isolates containers from <strong>each other</strong> and stops there. The two directions that matter
for exfiltration — your network, and the internet — are wide open, and no Docker guide frames
that as a gap because, for a web application, it isn't one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--the-floor">Step 2 — The floor<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#step-2--the-floor" class="hash-link" aria-label="Direct link to Step 2 — The floor" title="Direct link to Step 2 — The floor" translate="no">​</a></h2>
<p>One rule. Note <code>-A</code> rather than <code>-I</code>: the deny goes at the <strong>bottom</strong> of the chain and stays
there, and every allow added later stacks above it. That ordering is the policy.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-A</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> br-agent </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> DROP</span><br></div></code></pre></div></div>
<p><code>DOCKER-USER</code> is where this belongs, and Docker's documentation is explicit about why:</p>
<blockquote>
<p>Rules appended to the <code>FORWARD</code> chain will be processed after Docker's rules.</p>
</blockquote>
<p><code>DOCKER-USER</code> is processed <em>before</em> them — the docs describe it as <em>"a placeholder for
user-defined rules that will be processed before rules in the <code>DOCKER-FORWARD</code> and <code>DOCKER</code>
chains."</em> Anywhere else and Docker's own <code>ACCEPT</code> gets there first.</p>
<!-- -->
<p>Read that left to right and the placement explains itself. <code>DOCKER-FORWARD</code> holds one
unconditional <code>ACCEPT</code> per bridge — that is what makes container egress work at all, and it is
why a rule appended to <code>FORWARD</code> never fires. <code>DOCKER-USER</code> is the only hook that sits upstream
of it.</p>
<p>Re-run the same four probes:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">inference API   exit=28 Resolving timed out after 8000 milliseconds</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">arbitrary host  exit=28 Resolving timed out after 8002 milliseconds</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">authentik LAN   exit=28 Connection timed out after 8003 milliseconds</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">other network   exit=28 Connection timed out after 8002 milliseconds</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The single DROP rule listed, and all four probes failing with two distinct error messages" src="https://development-wec.wiline.com/docs/assets/images/h7-default-deny-dd96eebe9dcfc37644f95bf30a45d1ae.png" width="1234" height="720" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Read the error text, not the failure.</strong> Two different messages, and the difference is free
diagnostic information:</p>
<ul>
<li class=""><strong>"Resolving timed out"</strong> — DNS itself was blocked. We never learned an address.</li>
<li class=""><strong>"Connection timed out"</strong> — the address was already known, the packet left, the drop caught it.</li>
</ul>
<p>The two by-name probes died at resolution; the two by-IP probes died at the socket. Which
tells us something not obvious: <strong>Docker's embedded resolver forwards its upstream queries
across <code>DOCKER-USER</code>.</strong> The resolver lives at <code>127.0.0.11</code> inside the container's namespace,
and it is tempting to assume that traffic never touches the host's forward path. It does.</p>
<p>Also note that every failure took the <strong>full eight seconds</strong>. A <code>DROP</code> produces timeouts; a
<code>REJECT</code> fails instantly. Stealth against debuggability, and this post chooses stealth — but
if you are still iterating on a policy, <code>REJECT</code> will save you a great deal of waiting.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-allowlist">Step 3 — The allowlist<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#step-3--the-allowlist" class="hash-link" aria-label="Direct link to Step 3 — The allowlist" title="Direct link to Step 3 — The allowlist" translate="no">​</a></h2>
<p>Before writing a rule for the resolver, find out which resolver the container actually uses:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> /etc/resolv.conf</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">nameserver 127.0.0.11</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">search corp.internal internal mesh.example.com</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">options edns0 trust-ad ndots:0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"># Based on host file: '/etc/resolv.conf' (internal resolver)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"># ExtServers: [8.8.8.8 8.8.4.4]</span><br></div></code></pre></div></div>
<p><code>ExtServers: [8.8.8.8 8.8.4.4]</code>. <strong>The container is not using this network's DNS.</strong> The host
resolves through <code>systemd-resolved</code> on <code>127.0.0.53</code>, which is a loopback address and therefore
useless inside a container's namespace, so Docker substituted Google Public DNS without
mentioning it.</p>
<p>Look at the inherited search domains in the same block (redacted here, but real on your box).
Every internal hostname that container looks up is being asked of <code>8.8.8.8</code>, suffixed with the
private domains it inherited from the host. Nobody chose that, and nothing surfaces it except
reading this file.</p>
<p>It also decides the rule: allow the resolver the container <strong>has</strong>, not the one you assumed.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">getent hosts inference.wiline.com</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">67.207.107.229  inference.wiline.com</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> br-agent </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> udp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">53</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8.8</span><span class="token plain">.8.8,8.8.4.4 </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> br-agent </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> tcp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">53</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8.8</span><span class="token plain">.8.8,8.8.4.4 </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> br-agent </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> tcp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">443</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">67.207</span><span class="token plain">.107.229 </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-S</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">-N DOCKER-USER</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -d 67.207.107.229/32 -i br-agent -p tcp -m tcp --dport 443 -j ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -d 8.8.4.4/32 -i br-agent -p tcp -m tcp --dport 53 -j ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -d 8.8.8.8/32 -i br-agent -p tcp -m tcp --dport 53 -j ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -d 8.8.4.4/32 -i br-agent -p udp -m udp --dport 53 -j ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -d 8.8.8.8/32 -i br-agent -p udp -m udp --dport 53 -j ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-A DOCKER-USER -i br-agent -j DROP</span><br></div></code></pre></div></div>
<p>Five allows on one deny. The same four probes, a third time:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">inference API   HTTP 401 exit=0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">arbitrary host  exit=28 Connection timed out after 8002 milliseconds</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">authentik LAN   exit=28 Connection timed out after 8003 milliseconds</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">other network   exit=28 Connection timed out after 8001 milliseconds</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The four probes after the allowlist: the inference API answering 401, and the other three destinations timing out at the connection stage rather than at DNS" src="https://development-wec.wiline.com/docs/assets/images/h7-allowlist-applied-71e84e955ac296d1b262e120254f5409.png" width="1240" height="560" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Look at line two.</strong> It says <em>"Connection timed out"</em>, not <em>"Resolving timed out"</em>. DNS is
open, so <code>example.com</code> resolved to a real address — and then the packet died. The lookup
succeeded and the socket did not.</p>
<p>That is the whole post in one line. A compromised process inside that container can discover
where to send your data and cannot send it.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>The conntrack rule this doesn't need</div><div class="admonitionContent_BuS1"><p>Every guide on this subject tells you to accept <code>RELATED,ESTABLISHED</code> traffic first, and
Docker's documentation shows the rule. This policy has no such rule, and the <code>401</code> above
proves the full TLS handshake and HTTP response came back anyway.</p><p>The reason is the direction. Our drop matches <code>-i br-agent</code> — traffic arriving <strong>from</strong> the
container. Return packets carry <code>-o br-agent</code>, never match it, pass through <code>DOCKER-USER</code>
untouched, and are accepted downstream by Docker's own connection-tracking rules.</p><p>You need the conntrack accept when your drop can match the return direction, which is the case
in the ingress examples that advice comes from. A direction-specific egress drop does not. Add
it anyway if you like — it costs nothing — but understand that copying it without understanding
it is how people conclude firewall rules are black magic.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--measuring-the-cost-twice">Step 4 — Measuring the cost, twice<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#step-4--measuring-the-cost-twice" class="hash-link" aria-label="Direct link to Step 4 — Measuring the cost, twice" title="Direct link to Step 4 — Measuring the cost, twice" translate="no">​</a></h2>
<p>A policy nobody measures is a policy someone will disable under load. So: what does six rules
cost?</p>
<p>The obvious approach, twenty connects with the rules on, then twenty with them off:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">i</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">seq</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">20</span><span class="token variable" style="color:#36acaa">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'%{time_connect}\n'</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> https://inference.wiline.com/v1/models</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sort</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">awk</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{a[NR]=$1} END{printf "rules ON  — median %.4fs  min %.4fs  max %.4fs  (n=%d)\n", a[int(NR/2)+1], a[1], a[NR], NR}'</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-F</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">rules ON  — median 0.1573s  min 0.0940s  max 0.3768s  (n=20)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">rules OFF — median 0.0936s  min 0.0911s  max 0.1576s  (n=20)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The naive measurement showing a 64 millisecond median difference between rules on and rules off" src="https://development-wec.wiline.com/docs/assets/images/h7-overhead-naive-f360e40c29ad6c2b0c7ff16a3663104c.png" width="1230" height="376" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>64 milliseconds.</strong> Which would be a serious finding, if it were true.</p>
<p>It is not. Six rules of packet matching is microseconds of kernel work, and the minimums give
the game away: <code>0.0940</code> against <code>0.0911</code>, near-identical. The fast path is the same in both
runs. The entire difference lives in the tail.</p>
<p><code>curl</code>'s <code>time_connect</code> is measured from the start of the request and therefore <strong>includes name
resolution</strong>. The first run paid a full round trip to <code>8.8.8.8</code>. By the second, the resolver
had the answer cached. We measured the order we ran them in.</p>
<p>Measure the TCP connect alone, by subtracting the lookup:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">i</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">seq</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">30</span><span class="token variable" style="color:#36acaa">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> agent-sandbox </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'%{time_namelookup} %{time_connect}\n'</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> https://inference.wiline.com/v1/models</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">awk</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{print ($2-$1)}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sort</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">awk</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{a[NR]=$1} END{printf "connect-only median %.5fs  min %.5fs  max %.5fs  (n=%d)\n", a[int(NR/2)+1], a[1], a[NR], NR}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">rules ON  — connect-only median 0.07098s  min 0.06999s  max 0.08607s  (n=30)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">rules OFF — connect-only median 0.07102s  min 0.06996s  max 0.07214s  (n=30)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The corrected measurement showing a median difference of four hundredths of a millisecond, with the firewalled path nominally faster" src="https://development-wec.wiline.com/docs/assets/images/h7-overhead-94f3b889647c2c273680e82c050b345a.png" width="1240" height="452" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>A median difference of −0.04 ms.</strong> Negative: the firewalled path measured marginally
<em>faster</em>, which is what noise looks like. Everything sits inside about ±40 µs.</p>
<p>So the honest answer is not a number with a plus sign in front of it. It is that the cost of
this policy is below what this method can resolve — and that the naive version of the same
measurement was wrong by three orders of magnitude, in the direction that would have talked
you out of deploying it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--surviving-a-reboot">Step 5 — Surviving a reboot<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#step-5--surviving-a-reboot" class="hash-link" aria-label="Direct link to Step 5 — Surviving a reboot" title="Direct link to Step 5 — Surviving a reboot" translate="no">​</a></h2>
<p><code>iptables</code> rules live in memory. That <code>-F</code> in the last step did to the policy exactly what a
reboot does.</p>
<p>The reflex is <code>iptables-persistent</code>, and it is the wrong tool here: it saves the whole table
including every rule Docker generated, and Docker regenerates those itself at start. You get
duplicates and an ordering nobody chose.</p>
<p>What you want is a unit that owns <strong>only your rules</strong> and runs <strong>after</strong> Docker:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">/usr/local/sbin/agent-egress.sh</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token shebang important">#!/usr/bin/env bash</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Egress allowlist for the agent bridge. Re-applied after Docker starts.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-euo</span><span class="token plain"> pipefail</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">BR</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">br-agent</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">DNS</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"8.8.8.8 8.8.4.4"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">API</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">getent hosts inference.wiline.com </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">awk</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'{print $1; exit}'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Remove only our own rules, so this is safe to run twice.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># `|| true` matters: on a clean chain grep exits 1 and would abort the script.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">existing</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">iptables </span><span class="token variable parameter variable" style="color:#36acaa">-S</span><span class="token variable" style="color:#36acaa"> DOCKER-</span><span class="token variable environment constant" style="color:#36acaa">USER</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">" -i </span><span class="token variable string variable" style="color:#36acaa">${BR}</span><span class="token variable string" style="color:#e3116c"> "</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">||</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable boolean" style="color:#36acaa">true</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${existing}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">then</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'%s\n'</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${existing}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^-A /-D /'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">while</span><span class="token plain"> </span><span class="token builtin class-name">read</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> rule</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> iptables </span><span class="token variable" style="color:#36acaa">${rule}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">fi</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">d</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">${DNS}</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${BR}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> udp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">53</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${d}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${BR}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> tcp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">53</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${d}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${BR}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> tcp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">443</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${API}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> ACCEPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">iptables </span><span class="token parameter variable" style="color:#36acaa">-A</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">${BR}</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> DROP</span><br></div></code></pre></div></div>
<div class="language-ini codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">/etc/systemd/system/agent-egress.service</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ini codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[Unit]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Description=Egress allowlist for the agent bridge</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">After=docker.service</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Requires=docker.service</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">[Service]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Type=oneshot</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RemainAfterExit=yes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ExecStart=/usr/local/sbin/agent-egress.sh</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">[Install]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WantedBy=multi-user.target</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">chmod</span><span class="token plain"> +x /usr/local/sbin/agent-egress.sh</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> systemctl daemon-reload </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> systemctl </span><span class="token builtin class-name">enable</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--now</span><span class="token plain"> agent-egress.service</span><br></div></code></pre></div></div>
<p>Prove it is idempotent by restarting it and comparing the chain to itself:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> systemctl restart agent-egress.service </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-S</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same six rules listed after two consecutive restarts of the unit, identical both times" src="https://development-wec.wiline.com/docs/assets/images/h7-persistent-unit-08c4df2a28634e493a6a37476e9429fb.png" width="1230" height="610" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Six rules, twice, no duplicates.</p>
<p>Two things this buys quietly. <code>getent</code> runs on every start, so <strong>the API address is re-resolved
at boot</strong> — not continuous, but the cheap majority of the moving-target problem with no extra
daemon. And the delete loop strips only rules matching <code>-i br-agent</code>, so the unit never touches
anything else in the chain.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can put a default-deny egress policy in front of a container, allow exactly the destinations
it needs, tell a DNS failure apart from a socket failure by reading the error, measure the cost
without fooling yourself, and make the whole thing survive a reboot.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="if-you-switch-docker-to-the-nftables-backend">If you switch Docker to the nftables backend<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#if-you-switch-docker-to-the-nftables-backend" class="hash-link" aria-label="Direct link to If you switch Docker to the nftables backend" title="Direct link to If you switch Docker to the nftables backend" translate="no">​</a></h2>
<p>Docker 29 ships an nftables firewall backend alongside iptables. It is not the default and this
post did not run it — the switch rebuilds every rule on the host, and verifying the full
behaviour needs a reboot. What follows is from Docker's documentation, flagged as such.</p>
<p>Everything above stops applying, because:</p>
<blockquote>
<p>In Docker's nftables implementation, there is no <code>DOCKER-USER</code> chain.</p>
</blockquote>
<p>You create your own table instead, and the priority does the work <code>DOCKER-USER</code> used to:</p>
<blockquote>
<p>If your rules need to run before Docker's rules, give the base chains a lower priority number
than Docker's chain.</p>
</blockquote>
<p>And a rule that would be final under iptables is not:</p>
<blockquote>
<p>In nftables, an "accept" rule is not final. It terminates processing for its base chain, but
the accepted packet will still be processed by other base chains, which may drop it.</p>
</blockquote>
<p>Overriding Docker's drop then requires <code>--bridge-accept-fwmark</code>.</p>
<p>The trap worth knowing about is the transition, and it is quiet in both directions:</p>
<blockquote>
<p>When starting the daemon with nftables after running with iptables, Docker will not remove the
jump from the <code>FORWARD</code> chain to <code>DOCKER-USER</code>. So, rules created in <code>DOCKER-USER</code> will
continue to run until the jump is removed or the host is rebooted. When starting with nftables,
the daemon will not add the jump. So, unless there is an existing jump, rules in <code>DOCKER-USER</code>
will be ignored.</p>
</blockquote>
<p>Switch backends and your policy keeps working — until the next reboot, when it silently stops.
<code>iptables -S DOCKER-USER</code> will still list every rule.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-systemd-unit-fails-on-its-first-run-and-works-on-the-second">The systemd unit fails on its first run and works on the second<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#the-systemd-unit-fails-on-its-first-run-and-works-on-the-second" class="hash-link" aria-label="Direct link to The systemd unit fails on its first run and works on the second" title="Direct link to The systemd unit fails on its first run and works on the second" translate="no">​</a></h3>
<p><code>status=1/FAILURE</code> with nothing else in the journal. The cleanup line is the cause: on an empty
chain, <code>grep</code> finds no matching rules and exits <code>1</code>, which <code>set -euo pipefail</code> turns into an
abort before any rule is added. The idempotency guard breaks the one run that needs no
idempotency. See the <code>|| true</code> in step 5.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="everything-times-out-including-things-you-allowed">Everything times out, including things you allowed<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#everything-times-out-including-things-you-allowed" class="hash-link" aria-label="Direct link to Everything times out, including things you allowed" title="Direct link to Everything times out, including things you allowed" translate="no">​</a></h3>
<p>Check whether the failure says <em>"Resolving"</em> or <em>"Connection"</em>. If it says Resolving, your DNS
rule is the problem and the destination rule was never reached. Containers frequently do not
use the resolver you expect — read <code>/etc/resolv.conf</code> inside the container, not on the host.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-rules-are-listed-but-nothing-is-filtered">The rules are listed but nothing is filtered<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#the-rules-are-listed-but-nothing-is-filtered" class="hash-link" aria-label="Direct link to The rules are listed but nothing is filtered" title="Direct link to The rules are listed but nothing is filtered" translate="no">​</a></h3>
<p>Check that the bridge is up: <code>ip -br link show br-agent</code>. A network with no attached container
has a <code>DOWN</code> interface, and a rule matching <code>-i</code> on it matches nothing.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="everything-takes-exactly-eight-seconds-to-fail">Everything takes exactly eight seconds to fail<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#everything-takes-exactly-eight-seconds-to-fail" class="hash-link" aria-label="Direct link to Everything takes exactly eight seconds to fail" title="Direct link to Everything takes exactly eight seconds to fail" translate="no">​</a></h3>
<p>That is <code>DROP</code> behaving correctly. Use <code>REJECT</code> while iterating if you would rather fail fast.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-measurement-shows-tens-of-milliseconds-of-overhead">The measurement shows tens of milliseconds of overhead<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#the-measurement-shows-tens-of-milliseconds-of-overhead" class="hash-link" aria-label="Direct link to The measurement shows tens of milliseconds of overhead" title="Direct link to The measurement shows tens of milliseconds of overhead" translate="no">​</a></h3>
<p>You are measuring DNS. Use <code>%{time_connect}</code> minus <code>%{time_namelookup}</code>, and be suspicious of
any result where the minimums match but the medians don't.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: a container that can reach exactly one destination and a resolver; an exfiltration attempt
that fails after a successful DNS lookup rather than before it; a policy that reapplies itself
after a reboot and re-resolves its target when it does; and a measured cost indistinguishable
from zero.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>DNS is still an exfiltration channel.</strong> We allowed queries to <code>8.8.8.8</code>, and data can be
smuggled inside them. Closing that means a resolver you control and a rule pointing only at
it — which is a different post.</li>
<li class=""><strong>The allowlist is an IP, and IP addresses move.</strong> The unit re-resolves at boot, which is not
the same as following a CDN. A forward proxy filtering on TLS SNI is the honest answer for a
destination that rotates; it is also considerably more machinery.</li>
<li class=""><strong>This protects containers.</strong> The agents in
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">agent orchestration part 3</a> and
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">part 4</a> run as host processes, where these rules do
not apply. Moving them into a container is the prerequisite, and is worth doing on its own
merits.</li>
<li class=""><strong>The host is unrestricted</strong>, and so is every other container on this box. Five other bridges
still have an unconditional <code>ACCEPT</code> in <code>DOCKER-FORWARD</code>.</li>
<li class=""><strong>Nothing here stops the injection.</strong> It stops the payload leaving. Those are different jobs.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Where the agent can go, not just what it can call</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The obvious sequel is the resolver: run one you control, point the container at it, allow port
53 only to that address, and the DNS channel closes along with the dependency on Google. It also
sets up domain-based allowlisting properly, which is the piece of the moving-target problem this
post left open.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener noreferrer" class="">Docker — Packet filtering and firewalls</a></li>
<li class=""><a href="https://docs.docker.com/engine/network/firewall-iptables/" target="_blank" rel="noopener noreferrer" class="">Docker — Docker with iptables</a></li>
<li class=""><a href="https://docs.docker.com/engine/network/firewall-nftables/" target="_blank" rel="noopener noreferrer" class="">Docker — Docker with nftables</a></li>
<li class=""><a href="https://docs.docker.com/engine/network/drivers/bridge/" target="_blank" rel="noopener noreferrer" class="">Docker — Bridge network driver options</a></li>
<li class=""><a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" target="_blank" rel="noopener noreferrer" class="">OWASP — LLM06:2025 Excessive Agency</a></li>
<li class=""><a href="https://nvd.nist.gov/vuln/detail/CVE-2025-32711" target="_blank" rel="noopener noreferrer" class="">NVD — CVE-2025-32711 (EchoLeak)</a></li>
<li class=""><a href="https://nvd.nist.gov/vuln/detail/CVE-2024-8309" target="_blank" rel="noopener noreferrer" class="">NVD — CVE-2024-8309 (LangChain)</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>docker</category>
            <category>networking</category>
            <category>iptables</category>
            <category>firewall</category>
            <category>agents</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[The wrong valid token: authenticating an MCP tools server with authentik]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/</guid>
            <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Agent orchestration part 4 scoped tools in the client and admitted the server still trusted whoever reached the port. This puts a JWT verifier in front of it, with its own authentik client, so the tools server refuses anonymous callers — and refuses the gateway's own valid token, because a separate provider means a separate issuer. Includes the documented client-credentials helper that cannot work here, and the four silent 404s it produces instead of saying so.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__authentik" src="data:image/png;base64,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" alt="authentik"><span class="tutorialHero__plus">+</span><img class="tutorialHero__mcp" src="data:image/png;base64,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" alt="Model Context Protocol"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">6</span><span class="skillTracker__skill" data-state="current">A tool server that checks who is asking</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/">Part 5</a> gave an agent its own identity at
the gateway: a token issued by authentik over client credentials, carrying a scope, expiring in
five minutes. It ended by naming what it had not covered — the MCP tools server from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">agent orchestration part 4</a> still trusts anything that
can reach its port, <code>issue_refund</code> included.</p>
<p>That post was explicit about the limit of what it had built:</p>
<blockquote>
<p><strong>The MCP server still trusts everyone.</strong> Scoping happens in the <em>client</em>. Anything that can
reach <code>127.0.0.1:8770</code> can call <code>issue_refund</code> directly, agent or not.</p>
</blockquote>
<p>Splitting the toolbox per role stopped an <em>agent</em> from reaching a tool it shouldn't. It did
nothing about a <code>curl</code>. This part closes that.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>The same box as Parts 3 through 5: one WEC Instance, 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu
22.04, Python 3.10. authentik <code>2026.8.1</code> on host port <code>9100</code>. <code>fastmcp</code> 4.0.2, <code>mcp</code> 2.1.1,
<code>langchain</code> 1.4.0. The MCP server is <code>office_tools.py</code> from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">agent orchestration part 3</a>, unchanged except
for the lines shown here, still on <code>127.0.0.1:8770</code>.</p><p>Plain HTTP on a private LAN, as throughout this series.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-client-or-two">One client or two<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#one-client-or-two" class="hash-link" aria-label="Direct link to One client or two" title="Direct link to One client or two" translate="no">​</a></h2>
<p>The obvious move is to reuse Part 5's <code>agent-gateway</code> client and add a second scope to it. It
already exists, the agent already holds its credentials, and it would work.</p>
<p>Don't. If one credential opens both the gateway and the tools server, then a leaked gateway key
also issues refunds, and the blast radius of losing it doubles. The whole thread running through
Part 4's groups and Part 5's grant types is that a credential should do one job.</p>
<p>So the tools server gets its own client, its own scope, and — because in authentik every
provider is its own issuer — its own issuer. That last part turns out to matter more than
expected, and we get to it in Step 4.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">The authentik from <a class="" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/">Part 3</a>, with the <code>agent-gateway</code>
client from <a class="" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/">Part 5</a></li>
<li class="">The MCP server and virtualenv from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">agent orchestration part 3</a></li>
<li class=""><code>fastmcp</code> 4.0.0 or newer — the client-credentials helper discussed in Step 5 does not exist
before it</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--a-second-client-in-three-parts">Step 1 — A second client, in three parts<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-1--a-second-client-in-three-parts" class="hash-link" aria-label="Direct link to Step 1 — A second client, in three parts" title="Direct link to Step 1 — A second client, in three parts" translate="no">​</a></h2>
<p>This is Part 5's procedure with different names, so it is deliberately terse here. If any of it
is unfamiliar, <a class="" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/">Part 5 walks through the same three screens</a>
with figures.</p>
<p><strong>The provider.</strong> Applications → Providers → New Provider → OAuth2/OpenID → Next. Name
<code>agent-tools</code>, Client Type <strong>Confidential</strong>, Redirect URIs empty, and in <strong>Grant Types</strong> uncheck
everything except <strong>Client credentials</strong>.</p>
<p><strong>The scope.</strong> Customization → Property Mappings → New Property Mapping → Scope Mapping. Mapping
Name <code>mcp-invoke</code>, Scope name <code>mcp:invoke</code>, expression <code>return {}</code>. The two name fields are
different things: the first is authentik's label, the second is the string that lands in the
token.</p>
<p><strong>The application.</strong> Applications → Applications → New Application ▾ → <strong>with Existing
Provider…</strong>, named <code>Agent Tools</code>, slug <code>agent-tools</code> typed by hand, provider <code>agent-tools</code>,
hidden from the dashboard. Then Edit the provider → Advanced protocol settings → Scopes, and
move <code>mcp-invoke</code> into Selected.</p>
<p>Verify the grant types from the database rather than the form, which is
<a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">Part 4's lesson</a>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.providers.oauth2.models import OAuth2Provider</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for p in OAuth2Provider.objects.all():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f'{p.name}: {p.grant_types}')"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">agent-gateway: ['client_credentials']</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">agent-tools: ['client_credentials']</span><br></div></code></pre></div></div>
<p>Two clients trimmed to one grant each, and — visible in the same output — the LiteLLM provider
from Part 4 still carrying <code>password</code> and five others nobody chose. Part 5 said to go back and
fix those. This is what not doing it looks like.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--a-token-and-why-it-is-a-different-token">Step 2 — A token, and why it is a different token<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-2--a-token-and-why-it-is-a-different-token" class="hash-link" aria-label="Direct link to Step 2 — A token, and why it is a different token" title="Direct link to Step 2 — A token, and why it is a different token" translate="no">​</a></h2>
<p>Read the credentials out and keep them beside the gateway's, in their own directory:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">read</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> TID TSEC </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-T</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.providers.oauth2.models import OAuth2Provider as P</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">p = P.objects.get(name='agent-tools')</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print(p.client_id, p.client_secret)"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> -1</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/mcp-auth </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-auth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">umask</span><span class="token plain"> 077</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">EOF</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_CLIENT_ID=</span><span class="token string variable" style="color:#36acaa">$TID</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_CLIENT_SECRET=</span><span class="token string variable" style="color:#36acaa">$TSEC</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cp</span><span class="token plain"> ~/agent-auth/token.sh ~/mcp-auth/token.sh</span><br></div></code></pre></div></div>
<p>The helper is Part 5's, unchanged — it takes a scope and returns an access token.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/mcp-auth/token.sh mcp:invoke </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-R</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"iss"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/agent-tools/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"sub"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"545a532b77a0f877339a5e4312970069deeb9ded604494c510debdfdce9c970d"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"aud"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"xNxmOEyPMPVThmxgLsyvTCHa3qvnpVjQSOY0bZck"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"exp"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1789050030</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"iat"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1789049730</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"scope"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"mcp:invoke"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The decoded tools token showing an issuer ending in agent-tools, an audience different from the gateway client, and scope mcp" src="https://development-wec.wiline.com/docs/assets/images/h6-tools-token-611a3b28adc48e09f38c168c63c7d1ac.png" width="1108" height="244" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Three fields differ from the gateway's token and all three are load-bearing. The <strong>issuer</strong> ends
<code>/agent-tools/</code> rather than <code>/agent-gateway/</code>. The <strong>audience</strong> is a different client ID. The
<strong>scope</strong> is <code>mcp:invoke</code>. <code>exp</code> minus <code>iat</code> is 300 seconds, same as Part 5 — remember that
number, it comes back in Step 5.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Attaching a scope is not granting it</div><div class="admonitionContent_BuS1"><p>If the token comes back with <code>"scope": ""</code>, the mapping exists but is not attached to the
provider. authentik's own help text on that pane: <em>"Select which scopes can be used by the
client. The client still has to specify the scope to access the data."</em> Attaching makes it
available; the client still has to ask.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-verifier">Step 3 — The verifier<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-3--the-verifier" class="hash-link" aria-label="Direct link to Step 3 — The verifier" title="Direct link to Step 3 — The verifier" translate="no">​</a></h2>
<p>FastMCP validates JWTs with <code>JWTVerifier</code>, which takes the four things authentik just told us.
Back up the server first, since it is the artefact two published parts are built on:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cp</span><span class="token plain"> ~/mcp-tools/office_tools.py ~/mcp-tools/office_tools.py.bak</span><br></div></code></pre></div></div>
<p>Its configuration goes in a file rather than the source:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">umask</span><span class="token plain"> 077</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env.mcp </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">MCP_JWKS_URI=http://10.80.4.212:9100/application/o/agent-tools/jwks/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">MCP_ISSUER=http://10.80.4.212:9100/application/o/agent-tools/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">MCP_AUDIENCE=&lt;the agent-tools client id&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p>Then two changes to <code>office_tools.py</code> — an import, and a verifier where the bare constructor was:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/office_tools.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> FastMCP</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Context</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">server</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">auth</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">providers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">jwt </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> JWTVerifier</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">verifier </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> JWTVerifier</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    jwks_uri</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"MCP_JWKS_URI"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    issuer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"MCP_ISSUER"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    audience</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"MCP_AUDIENCE"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    required_scopes</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"mcp:invoke"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">mcp </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> FastMCP</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"office-tools"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> auth</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">verifier</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>That is the entire server-side change. <code>JWTVerifier</code> fetches the JWKS, matches each token's
<code>kid</code> to a key, and checks signature, issuer, audience, expiry and scope before any tool runs.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span><code>ssrf_safe</code> and private addresses</div><div class="admonitionContent_BuS1"><p><code>JWTVerifier</code> takes an <code>ssrf_safe</code> argument that defaults to <code>False</code>, and enabling it sounds
unambiguously like a good idea. On a setup like this one it breaks the verifier. FastMCP's SSRF
guard rejects on IP, and its own error text is <em>"Private, loopback, link-local, and reserved IPs
are not allowed"</em> — the check explicitly covers <code>10.x</code>, <code>172.16-31.x</code>, <code>192.168.x</code> and <code>127.x</code>.
Our JWKS is at <code>10.80.4.212</code>, so the fetch never leaves the process.</p><p>If you want the guard on and your identity provider is genuinely internal, the module reads
<code>FASTMCP_SSRF_TRUST_PROXY</code>, which skips DNS resolution and the IP blocklist entirely.</p></div></div>
<p>Restart with the new environment loaded:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">pkill</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> office_tools.py</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> .env.mcp </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">nohup</span><span class="token plain"> ./.venv/bin/python </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> office_tools.py </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> server.log </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">4</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-8</span><span class="token plain"> server.log</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The server restarting, its startup log reporting transport and port with no mention of authentication" src="https://development-wec.wiline.com/docs/assets/images/h6-server-auth-startup-e2aacc2f7033430c7a21caae3e07b195.png" width="856" height="326" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Read that startup log carefully, because of what is missing from it. It reports the transport,
the mode and the port, and says nothing whatsoever about authentication. A server with a
verifier attached and a server without one log the same lines. There is no "auth enabled"
message to look for, so the only way to know the verifier took is to make a request — which is
the next step.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--three-requests">Step 4 — Three requests<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-4--three-requests" class="hash-link" aria-label="Direct link to Step 4 — Three requests" title="Direct link to Step 4 — Three requests" translate="no">​</a></h2>
<p>The same shape as Part 5's three status codes, because it reads well and because the middle one
is the interesting one:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">TOOLS</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/mcp-auth/token.sh mcp:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">GW</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/agent-auth/token.sh gateway:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function-name function" style="color:#d73a49">req</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$1</span><span class="token string" style="color:#e3116c">  HTTP %{http_code}</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8770/mcp </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Accept: application/json, text/event-stream'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token variable" style="color:#36acaa">${2</span><span class="token variable operator" style="color:#393A34">:+</span><span class="token variable" style="color:#36acaa">-H "Authorization</span><span class="token variable operator" style="color:#393A34">:</span><span class="token variable" style="color:#36acaa"> Bearer $2"}</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">req </span><span class="token string" style="color:#e3116c">"no token     "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">req </span><span class="token string" style="color:#e3116c">"gateway token"</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$GW</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">req </span><span class="token string" style="color:#e3116c">"tools token  "</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$TOOLS</span><span class="token string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">no token       HTTP 401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">gateway token  HTTP 401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">tools token    HTTP 200</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Three requests to the MCP server printing no token 401, gateway token 401 and tools token 200" src="https://development-wec.wiline.com/docs/assets/images/h6-three-way-5268dcb96fa1731e359bf14df5c1b0e8.png" width="633" height="284" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The first line is the gap agent orchestration part 4 admitted to, now closed. Before this change
that request returned the full five-tool catalogue to anybody who asked — and with the right
token, it still does:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8770/mcp </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$TOOLS</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Accept: application/json, text/event-stream'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tr</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'\r'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^data: //p'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.result.tools | length, (.[].name)'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The tools list returned to an authorised caller: five tools, find_customer, list_availability, book_slot, open_invoices and issue_refund" src="https://development-wec.wiline.com/docs/assets/images/h6-tools-list-authorised-e04041592339f5df452a50d4fc2cd30d.png" width="665" height="206" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Same five tools as before. Authentication changed who may ask, not what the server does.</p>
<p>The second is the one worth pausing on. That is a <strong>valid, unexpired, correctly signed authentik
token</strong> that works perfectly well against the gateway, and the tools server refuses it. Ask the
server why — sending the request again first, so the answer is at the end of the log rather
than buried under whatever has hit the server since:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">GW</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/agent-auth/token.sh gateway:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'gateway token: HTTP %{http_code}\n'</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8770/mcp </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$GW</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Accept: application/json, text/event-stream'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-12</span><span class="token plain"> ~/mcp-tools/server.log</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WARNING  Bearer token rejected for client aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">         : issuer mismatch (got 'http://10.80.4.212:9100/application/o/agent-gateway/',</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                       expected 'http://10.80.4.212:9100/application/o/agent-tools/')</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO     Auth error returned: invalid_token (status=401)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The server log naming the rejected client and printing both the received and expected issuer, followed by a generic invalid_token response" src="https://development-wec.wiline.com/docs/assets/images/h6-issuer-mismatch-log-67e89b1720edb880e97f3f7258d47aac.png" width="690" height="376" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>It failed on <code>iss</code>, not on scope.</strong> That is the separate provider paying off. In authentik each
provider is its own issuer, so a token from the wrong client is rejected at the first gate,
before audience or scope are looked at. Had we taken the easy road and added a second scope to
the gateway's client, the same request would have got several checks further before
<code>required_scopes</code> turned it away. Same outcome, more surface.</p>
<p>Two other things in that log. It names the offending client by ID, and prints both the issuer it
got and the one it wanted — so debugging is a <code>tail</code>, not a guess. And the caller receives only
<code>invalid_token</code>, which is correct: telling a client <em>which</em> issuer you expected is telling an
attacker what to forge.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Don't grep this log</div><div class="admonitionContent_BuS1"><p><code>grep -iE 'invalid|token|401' server.log</code> returns the last line and hides the first. You get
<code>invalid_token (status=401)</code> and conclude the server won't say why. It says why, on the line
above, wrapped across four lines that your pattern doesn't match. Read it unfiltered.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--the-client-half-and-a-helper-that-cannot-help">Step 5 — The client half, and a helper that cannot help<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-5--the-client-half-and-a-helper-that-cannot-help" class="hash-link" aria-label="Direct link to Step 5 — The client half, and a helper that cannot help" title="Direct link to Step 5 — The client half, and a helper that cannot help" translate="no">​</a></h2>
<p>The tools server is now protected, which means every script from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">part 3</a> and
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">part 4</a> is broken. They connect with no token.</p>
<p>FastMCP documents exactly the right thing for this: <code>ClientCredentialsOAuthProvider</code>, which runs
the client-credentials grant itself and — the part that matters given our 300-second tokens —
<em>"when the token expires it is re-acquired automatically on the next request."</em></p>
<p>It does not work here. Try it and you get:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">mcp.client.auth.exceptions.OAuthTokenError: Token exchange failed (404): Not Found</span><br></div></code></pre></div></div>
<p>The server log explains what actually happened:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">GET  /.well-known/oauth-authorization-server      404</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">POST /token                                        404</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GET  /.well-known/oauth-protected-resource         404</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GET  /.well-known/oauth-protected-resource/mcp     404</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GET  /.well-known/openid-configuration             404</span><br></div></code></pre></div></div>
<p>The provider takes the <strong>MCP server's</strong> URL, not a token endpoint, because <em>"the token endpoint
is discovered from the server's OAuth metadata."</em> Our server publishes none — and FastMCP's own
documentation says so, on a different page: <em>"<code>TokenVerifier</code> focuses exclusively on token
validation without providing OAuth discovery metadata."</em></p>
<p>So the two pages are consistent and the combination is a documented dead end. You just have to
read both to find that out, and the failure tells you none of it: four 404s you only see if you
are watching the server, a fallback that POSTs <code>/token</code> at the resource server, and an error
that never mentions discovery.</p>
<p>The path the docs point to instead is the client obtaining its token separately. <code>BearerAuth</code>
takes a token you already have and attaches it to every request — worth proving standalone
before wiring it into four scripts:</p>
<p><span class="zoomImage__wrap"><img alt="A probe script using BearerAuth with a token from the shell helper, printing all five discovered tool names" src="https://development-wec.wiline.com/docs/assets/images/h6-client-bearer-works-5250c1328730dcadf4366a6843f881c0.png" width="683" height="427" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>That works, so it can be shared. One helper, used by every script in the series:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/mcp_auth.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""One authenticated MCP client, shared by every agent in the series."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> subprocess</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">auth </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> BearerAuth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MCP_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://127.0.0.1:8770/mcp"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">TOKEN_SH </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">expanduser</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"~/mcp-auth/token.sh"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">fetch_token</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">scope</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"mcp:invoke"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""Mint a short-lived token from authentik via the client credentials grant."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> subprocess</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">TOKEN_SH</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> scope</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> capture_output</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> text</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> check</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">stdout</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">authed_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Client</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">MCP_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> auth</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">BearerAuth</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">token</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">fetch_token</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Each agent then changes by two lines — an import, and what it hands <code>MCPAdapter</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> mcp_auth </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> authed_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> MCPAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">authed_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">:</span><br></div></code></pre></div></div>
<p><code>MCPAdapter</code> accepts a configured <code>Client</code> as readily as a URL string, which
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">part 3 used for caching</a> without needing it
for anything else. Here it is what lets the token in.</p>
<p><code>BearerAuth</code> is the explicit form. FastMCP also accepts the token as a bare string —
<code>Client(url, auth=token)</code> — and adds the scheme itself; its documentation is specific that you
<em>"do not include the <code>Bearer</code> prefix"</em> if you do. The class is worth the extra import here only
because it makes the intent obvious at the call site.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>This reintroduces the expiry problem</div><div class="admonitionContent_BuS1"><p><code>BearerAuth</code> holds one fixed string. A token minted when the agent starts is dead 300 seconds
later, and nothing re-acquires it — the automatic renewal was the one thing
<code>ClientCredentialsOAuthProvider</code> would have given us. For a short run it does not matter. For an
agent that waits on a human, as
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">part 3's refund does</a>, it certainly can. Either
raise the token lifetime on the provider, or mint per call rather than per client.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--prove-a-tool-actually-runs">Step 6 — Prove a tool actually runs<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#step-6--prove-a-tool-actually-runs" class="hash-link" aria-label="Direct link to Step 6 — Prove a tool actually runs" title="Direct link to Step 6 — Prove a tool actually runs" translate="no">​</a></h2>
<p>Discovery is not execution. A token that lists tools might still fail at the call, so check the
thing that touches the database:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">TOOLS</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/mcp-auth/token.sh mcp:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8770/mcp </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$TOOLS</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Accept: application/json, text/event-stream'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_customer","arguments":{"query":"Maria"}}}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tr</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'\r'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^data: //p'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.result.content[0].text // .error'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"[{\"id\":1,\"name\":\"Maria Alvarez\",\"email\":\"maria@example.com\"}]"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="A tools/call for find_customer, authenticated with a bearer token, returning Maria Alvarez&amp;#39;s row from the database" src="https://development-wec.wiline.com/docs/assets/images/h6-tool-call-authorised-d181de9df9cf4ce219d07511a7b381d2.png" width="841" height="155" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Drop the <code>Authorization</code> header and the same call returns 401. A real row with a token, nothing
without — that is authentication reaching execution, not just the catalogue.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can put a JWT verifier in front of an MCP tools server so it refuses anonymous callers and
refuses valid tokens issued to a different client — and read the server log to know which of
issuer, audience, scope or expiry did the refusing.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="token-exchange-failed-404-not-found"><code>Token exchange failed (404): Not Found</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#token-exchange-failed-404-not-found" class="hash-link" aria-label="Direct link to token-exchange-failed-404-not-found" title="Direct link to token-exchange-failed-404-not-found" translate="no">​</a></h3>
<p><code>ClientCredentialsOAuthProvider</code> against a <code>JWTVerifier</code> server. Discovery found nothing and it
fell back to guessing. See Step 5; use <code>BearerAuth</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-validation-errors--missing-required-argument"><code>2 validation errors ... Missing required argument</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#2-validation-errors--missing-required-argument" class="hash-link" aria-label="Direct link to 2-validation-errors--missing-required-argument" title="Direct link to 2-validation-errors--missing-required-argument" translate="no">​</a></h3>
<p>The token was fine — this comes from inside the tool. <code>params.name</code> is the tool's name,
<code>params.arguments</code> are its parameters, and both nest a field called <code>name</code>. <code>find_customer</code>
takes <code>query</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-log-says-only-invalid_token">The log says only <code>invalid_token</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#the-log-says-only-invalid_token" class="hash-link" aria-label="Direct link to the-log-says-only-invalid_token" title="Direct link to the-log-says-only-invalid_token" translate="no">​</a></h3>
<p>You grepped it. See the warning in Step 4.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="every-script-from-parts-3-and-4-suddenly-fails">Every script from parts 3 and 4 suddenly fails<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#every-script-from-parts-3-and-4-suddenly-fails" class="hash-link" aria-label="Direct link to Every script from parts 3 and 4 suddenly fails" title="Direct link to Every script from parts 3 and 4 suddenly fails" translate="no">​</a></h3>
<p>Expected. They connect without a token. Step 5.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: an MCP server that authenticates every caller, a credential that is not the gateway's, a
refusal that happens at the issuer rather than deep in the scope check, and a log that names
which client was turned away and why.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>No per-tool authorisation, and this is less than agent orchestration part 4 promised.</strong> That
post closed by saying the next step was to <em>"authenticate the tool call, so the server knows
which agent is asking and refuses <code>issue_refund</code> to anything that isn't billing."</em> Half of
that is now true: the call is authenticated. The other half is not. <code>required_scopes</code> is a
property of the server, not of a tool, so every holder of <code>mcp:invoke</code> can call <code>issue_refund</code>
exactly as easily as <code>find_customer</code>, and the server still cannot tell the scheduler from the
billing agent. Splitting tools per agent, as
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">part 4 did</a>, remains a client-side arrangement.</li>
<li class=""><strong>Tokens still expire mid-run</strong>, as described in Step 5.</li>
<li class=""><strong>Still plain HTTP.</strong> A bearer token in the clear is a credential in the clear.</li>
<li class=""><strong>The gateway's own provider is still over-permissioned</strong>, visible in Step 1's output and
unfixed since Part 5 pointed at it.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>A tool server that checks who is asking</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Per-tool authorisation is the obvious gap and it does not have an obvious answer. <code>required_scopes</code>
gates the server; gating <code>issue_refund</code> differently from <code>find_customer</code> means either a second
server with its own client, or authorisation inside each tool body reading the verified claims.
The first is more boxes and a clean boundary. The second is a check on every tool you ever write,
which is precisely the failure mode
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">part 4 warned about</a> for policy-in-the-tool.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://gofastmcp.com/servers/auth/token-verification" target="_blank" rel="noopener noreferrer" class="">FastMCP — token verification</a></li>
<li class=""><a href="https://gofastmcp.com/clients/auth/client-credentials" target="_blank" rel="noopener noreferrer" class="">FastMCP — machine-to-machine client authentication</a></li>
<li class=""><a href="https://gofastmcp.com/clients/auth/bearer" target="_blank" rel="noopener noreferrer" class="">FastMCP — bearer token authentication</a></li>
<li class=""><a href="https://docs.goauthentik.io/docs/providers/oauth2/client_credentials" target="_blank" rel="noopener noreferrer" class="">authentik — client credentials</a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc9728" target="_blank" rel="noopener noreferrer" class="">RFC 9728 — OAuth 2.0 Protected Resource Metadata</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>oauth2</category>
            <category>oidc</category>
            <category>authentik</category>
            <category>identity</category>
            <category>mcp</category>
            <category>agents</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[The key that expires: giving an agent its own identity at the gateway]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/</guid>
            <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 4 ended with a shell, a static key and a model that answered. This replaces that key with a token your identity provider issues, that carries a scope, and that dies after five minutes. authentik issues it over client credentials, the gateway verifies it in twenty-five lines, and three status codes prove the boundary. Includes the requirement authentik doesn't document, and the licence wall you hit if you follow LiteLLM's own guide.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__authentik" src="data:image/png;base64,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" alt="authentik"><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">5</span><span class="skillTracker__skill" data-state="current">An identity for the agent, not a key</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">Part 4</a> put group access control on the
gateway's admin UI and then, at the end, called the API from a shell with no account, no
session and no group. It answered normally. The conclusion was that SSO protects a control
plane and the data plane authenticates machine callers with keys — which it has to, because
an agent running at 3am cannot complete a browser login.</p>
<p>That was true and it was also a stopping point rather than an answer. "Machine callers use
keys" leaves you with a credential that never expires, that no identity provider knows about,
and that survives the person who created it. Part 4 said so plainly: removing someone from a
group does not revoke their keys, a leaked key is unaffected by identity entirely, and keys
outlive people.</p>
<p>This part gives the machine an identity instead of a key.</p>
<p>authentik issues the agent a token over the <strong>client credentials</strong> grant — no browser, no
consent screen, no human. The token is signed, carries a scope, and expires in five minutes.
The gateway verifies it locally against authentik's public keys and refuses anything without
the right scope. At the end, three status codes show the boundary holding.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. authentik
<code>2026.8.1</code>, the same instance from Parts 3 and 4 on host port <code>9100</code>. LiteLLM <code>1.96.2</code> from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 of the gateway series</a>, bound to
<code>127.0.0.1:4000</code>, models served over WEC Inference. Verification uses <code>PyJWT</code> 2.13.0 and
<code>cryptography</code> 50.0.0, both already present in the LiteLLM image.</p><p>Plain HTTP on a private LAN, as in Parts 3 and 4. Substitute your own addresses.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-grants-two-different-questions">Two grants, two different questions<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#two-grants-two-different-questions" class="hash-link" aria-label="Direct link to Two grants, two different questions" title="Direct link to Two grants, two different questions" translate="no">​</a></h2>
<p>Parts 3 and 4 used the <strong>authorization code</strong> grant throughout. A person clicks a button, gets
redirected to authentik, types a password, sees a consent screen, and is redirected back with
a code the application exchanges for a token. Every step of that assumes a browser and a human
attached to it.</p>
<p>The <strong>client credentials</strong> grant answers a different question. There is no user. The client
<em>is</em> the principal, and it proves that with an ID and a secret, in one request, with no
redirect anywhere.</p>
<!-- -->
<p>The property worth noticing: the gateway never asks authentik whether a token is good. It
fetches authentik's public keys once, caches them, and checks the signature itself. Identity
scales without the identity provider becoming a bottleneck — or a single point of failure on
every request.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A working authentik, ideally the one from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/">Part 3</a></li>
<li class="">A running LiteLLM gateway from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 of the gateway series</a></li>
<li class="">Its <code>LITELLM_MASTER_KEY</code>, and shell access to the box</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--a-client-with-exactly-one-grant">Step 1 — A client with exactly one grant<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#step-1--a-client-with-exactly-one-grant" class="hash-link" aria-label="Direct link to Step 1 — A client with exactly one grant" title="Direct link to Step 1 — A client with exactly one grant" translate="no">​</a></h2>
<p><strong>Applications → Providers → New Provider → OAuth2/OpenID Provider → Next.</strong></p>
<ul>
<li class=""><strong>Provider Name</strong> — <code>agent-gateway</code></li>
<li class=""><strong>Authorization Flow</strong> — leave <code>default-provider-authorization-explicit-consent</code>. It is
required, and it is inert here: it governs the consent screen a human sees, and no human
will ever use this client. You have to pick one anyway.</li>
<li class=""><strong>Client Type</strong> — <strong>Confidential</strong>. authentik's own description is the reason: <em>"Confidential
clients are capable of maintaining the confidentiality of their credentials such as client
secrets."</em> Client credentials has nothing but that secret to prove identity, so a public
client cannot use this grant at all.</li>
<li class=""><strong>Redirect URIs</strong> — leave empty. Nothing is ever redirected anywhere.</li>
</ul>
<p>Then scroll to <strong>Grant Types</strong>, and look at what a new provider ships with.</p>
<p><span class="zoomImage__wrap"><img alt="The Grant Types field on a new authentik provider with seven of eight boxes checked: Authorization Code, Implicit, Hybrid, Refresh token, Client credentials, Password and Device-code, with only Token exchange unchecked" src="https://development-wec.wiline.com/docs/assets/images/h5-grant-types-default-a874ef714983fd8cd1c64bf2b966cb09.png" width="1790" height="837" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Seven of eight, enabled by default. Two of them — <strong>Implicit</strong> and <strong>Password</strong> — did not make
it into OAuth 2.1, which states plainly that <em>"some features available in OAuth 2.0, such as the
Implicit or Resource Owner Credentials grant types, are not specified in OAuth 2.1"</em>
(<a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-10.1" target="_blank" rel="noopener noreferrer" class="">§10.1</a>). Password
in particular has the client handle a user's actual password, which is why it fell out of
favour. Nothing asked whether you wanted them and nothing warns you that they are on.</p>
<p>Uncheck everything except <strong>Client credentials</strong>.</p>
<p><span class="zoomImage__wrap"><img alt="The same Grant Types field with only Client credentials checked and the other seven boxes clear" src="https://development-wec.wiline.com/docs/assets/images/h5-grant-types-scoped-d5992f14733c7ab87134b9a587a05c87.png" width="1900" height="915" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>This is the same least-privilege reflex Part 4 applied to group membership, one layer down. A
client that only ever uses one grant should only be permitted one grant, so that a
misconfiguration elsewhere cannot turn it into a password-accepting endpoint.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Go back and check the providers you already made</div><div class="admonitionContent_BuS1"><p>Those defaults applied to every provider you created in Parts 3 and 4 too, and nothing has
trimmed them since. Ask the database rather than the UI:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.providers.oauth2.models import OAuth2Provider</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for p in OAuth2Provider.objects.all():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f'{p.name}: {p.grant_types}')"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">agent-gateway: ['client_credentials']</span><br></div></code></pre></div></div><p>The gateway's provider is still holding <code>password</code> and <code>client_credentials</code>
alongside the grant it actually uses. Neither was needed, and the password grant in particular
is a live way in that nobody put there on purpose. Trim each provider to the grants it uses.</p></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>An empty redirect list is not an empty rule</div><div class="admonitionContent_BuS1"><p>Read authentik's own help text on the Redirect URIs field: <em>"If no explicit authorization
redirect URIs are specified, the first successfully used authorization redirect URI will be
saved."</em></p><p>Empty does not mean "deny all". It means trust-on-first-use — authentik pins whichever
redirect URI shows up first and keeps it. That is harmless for a client which never redirects,
which is our case. It is not harmless on an interactive provider, where it means you have no
redirect validation at all until someone logs in and pins one for you. Part 3 warned against
setting the mode to <code>.*</code>; this is the quieter version of the same hazard.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--the-application-it-belongs-to">Step 2 — The application it belongs to<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#step-2--the-application-it-belongs-to" class="hash-link" aria-label="Direct link to Step 2 — The application it belongs to" title="Direct link to Step 2 — The application it belongs to" translate="no">​</a></h2>
<p>Read back the credentials and try to get a token. It will not work yet, and the reason is the
most useful thing in this post.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/agent-auth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">cd</span><span class="token plain"> ~/agent-auth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">umask</span><span class="token plain"> 077</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">EOF</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_CLIENT_ID=&lt;client id from the provider page&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AK_CLIENT_SECRET=&lt;client secret from the provider page&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p><code>umask 077</code> creates the file with 600 permissions rather than chmod-ing it afterwards, so there
is no window where the secret is world-readable.</p>
<p>A small helper, because you will fetch tokens constantly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> ~/agent-auth/token.sh </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">#!/usr/bin/env bash</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">set -euo pipefail</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">source "$(dirname "$0")/.env"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">curl -sS -X POST "$AK_TOKEN_URL" \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  -d grant_type=client_credentials \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  -d client_id="$AK_CLIENT_ID" \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  -d client_secret="$AK_CLIENT_SECRET" \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  -d scope="${1:-}" \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  | jq -r .access_token</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">chmod</span><span class="token plain"> +x ~/agent-auth/token.sh</span><br></div></code></pre></div></div>
<p>Ask for a token:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/agent-auth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">source</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$AK_TOKEN_URL</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">grant_type</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">client_credentials </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">client_id</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$AK_CLIENT_ID</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">client_secret</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$AK_CLIENT_SECRET</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"error"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"invalid_grant"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"error_description"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"The provided authorization grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"request_id"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"757c397b97a745259e288b6d4ff1553c"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Every clause in that description is wrong for our situation. Nothing is expired, nothing is
revoked, no redirection URI was used, and the client is the one the credentials belong to. The
server log records the 400 and no reason. authentik's Events show nothing useful either.</p>
<p>The actual cause is visible in the Providers list, which flags it plainly:</p>
<p><span class="zoomImage__wrap"><img alt="The authentik Providers list showing agent-gateway with a warning reading Provider not assigned to any application, beside two healthy providers assigned to Langfuse and LiteLLM" src="https://development-wec.wiline.com/docs/assets/images/h5-provider-unassigned-f6f2e4435e386fcaa6db74ac117566a4.png" width="1901" height="830" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>A provider with no application cannot issue a token.</strong> authentik authorizes against the
<em>application</em>, so a provider on its own has nothing to authorize against — and says
<code>invalid_grant</code> instead of saying that.</p>
<p>This is not in authentik's client credentials documentation, which documents five ways to
authenticate — three static-credential variants and two JWT ones — and says nothing about
needing an application.</p>
<p>Pair one. Note the route: the <strong>New Application</strong> wizard always creates a <em>new</em> provider, so it
cannot adopt the one we just built.</p>
<p><span class="zoomImage__wrap"><img alt="The New application wizard&amp;#39;s Configure the Application step, with its five-step list on the left reading Application, Choose a Provider, Configure Provider, Configure Bindings, Review and Submit" src="https://development-wec.wiline.com/docs/assets/images/h5-application-created-4cf5cd7238257dde87681d0c92e34efd.png" width="1894" height="913" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Its step list gives it away — <strong>Choose a Provider</strong> is step 2, and every option there builds a
new one. The caret beside the button is where the other path lives.</p>
<p><strong>Applications → Applications → New Application ▾ → with Existing Provider…</strong></p>
<ul>
<li class=""><strong>Name</strong> — <code>Agent Gateway</code></li>
<li class=""><strong>Slug</strong> — <code>agent-gateway</code>, typed explicitly. Part 4's finding was that authentik derives
slugs from names and turned <code>LiteLLM</code> into <code>lite-llm</code>; the slug lands in the issuer URL, so
it is worth setting by hand every time.</li>
<li class=""><strong>Provider</strong> — <code>agent-gateway</code></li>
<li class=""><strong>Hidden from Application Dashboard</strong> — check it. No person should ever see a tile for a
machine client.</li>
</ul>
<p>Retry the token request and it succeeds. Nothing changed except the pairing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--a-scope-that-means-something">Step 3 — A scope that means something<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#step-3--a-scope-that-means-something" class="hash-link" aria-label="Direct link to Step 3 — A scope that means something" title="Direct link to Step 3 — A scope that means something" translate="no">​</a></h2>
<p>The token you now get carries <code>"scope": ""</code>. It proves <em>who</em> the caller is and grants no
authority at all. A gateway receiving it knows the client is <code>agent-gateway</code> and nothing more.</p>
<p><strong>Customization → Property Mappings → New Property Mapping → Scope Mapping.</strong></p>
<ul>
<li class=""><strong>Mapping Name</strong> — <code>gateway-invoke</code>, authentik's label for the object</li>
<li class=""><strong>Scope name</strong> — <code>gateway:invoke</code>, the string clients request and that appears in the token</li>
<li class=""><strong>Expression</strong> — <code>return {}</code></li>
</ul>
<p><span class="zoomImage__wrap"><img alt="The Scope Mapping form with Mapping Name gateway-invoke, Scope name gateway, a description, and the expression return empty braces" src="https://development-wec.wiline.com/docs/assets/images/h5-scope-mapping-93c6d51e31e1b4e0e089d72f023f4714.png" width="1895" height="867" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The two name fields are different things and the form does not make that obvious. The
expression is Python whose return value is merged into the token's claims; an empty dict is
right here, because we want the scope <em>named</em> in the token, not extra data carried inside it.</p>
<p>Attach it: <strong>Applications → Providers → <code>agent-gateway</code> → Edit → Advanced protocol settings →
Scopes</strong>, and move <code>gateway-invoke</code> from Available to Selected.</p>
<p><span class="zoomImage__wrap"><img alt="The provider&amp;#39;s Scopes dual-list with email, openid, profile and gateway-invoke in the Selected column" src="https://development-wec.wiline.com/docs/assets/images/h5-provider-scopes-1cf3161c12ea30bfd90af1bc227bb8d1.png" width="1836" height="841" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Read the help text under that pane, because it is the whole mechanic:</p>
<blockquote>
<p><em>"Select which scopes can be used by the client. The client still has to specify the scope to
access the data."</em></p>
</blockquote>
<p>Attaching a scope makes it <em>available</em>. The client still has to ask. That is why the first
token came back empty even though three scopes were already attached — nothing had requested
them.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Clicking a selected scope marks it for removal</div><div class="admonitionContent_BuS1"><p>The dual-list marks entries for deletion when you click them in the right-hand column, and the
only indication is a line of small text above it reading <em>"4 items selected. 1 item marked to
remove."</em> Saving at that moment silently unlinks the scope you just added. Read that line
before you save.</p></div></div>
<p>Now ask for it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">TOKEN</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/agent-auth/token.sh gateway:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-R</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;&lt;&lt;</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$TOKEN</span><span class="token string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"iss"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/agent-gateway/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"sub"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"2451c6a1bab3085590263f119e650448beb013336146bcfee8241dd5832c200b"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"aud"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"exp"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1788901322</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"iat"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1788901022</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"acr"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"goauthentik.io/providers/oauth2/default"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"jti"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"NRDTukijwLJbW7gpPtn9zUZwzTj6d2kBSZCuFYvX"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"azp"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"uid"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"neugHWoWQOQzADgbV1vKwzew2COQ1RgCXfyywgqa"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"scope"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"gateway:invoke"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The decoded JWT payload showing iss, sub, aud, exp, jti, uid and scope set to gateway" src="https://development-wec.wiline.com/docs/assets/images/h5-token-scoped-83408e96e6123963ab117fedea8d70de.png" width="695" height="276" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><code>exp</code> minus <code>iat</code> is 300 seconds. That is the entire argument for this over a static key: a
token pasted into a chat window, a log file or a screenshot is worthless five minutes later.</p>
<p>There is no <code>email</code> or <code>preferred_username</code> in there, because there is no user. <code>sub</code> is a
service account authentik created for you, named <code>ak-agent-gateway-client_credentials</code> — it
appears in <strong>Directory → Users</strong> without ever being asked for.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--teaching-the-gateway-to-verify">Step 4 — Teaching the gateway to verify<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#step-4--teaching-the-gateway-to-verify" class="hash-link" aria-label="Direct link to Step 4 — Teaching the gateway to verify" title="Direct link to Step 4 — Teaching the gateway to verify" translate="no">​</a></h2>
<p>LiteLLM documents JWT authentication with a <code>litellm_jwtauth</code> block, <code>enforce_scope_based_access</code>
and <code>scope_mappings</code>. Configure it on a stock install and nothing happens, because the same page
says:</p>
<blockquote>
<p><em>"JWT-based Auth requires a LiteLLM Enterprise license."</em></p>
</blockquote>
<p>Check before you write config:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from litellm.proxy.proxy_server import premium_user</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('premium_user =', premium_user)"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">premium_user = False</span><br></div></code></pre></div></div>
<p><code>False</code> means that whole documented path is closed to you. The open-source alternative is
<code>custom_auth</code>, which hands every request to a function you write — and for this job it is about
twenty-five lines.</p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>custom_auth replaces key authentication entirely</div><div class="admonitionContent_BuS1"><p>Once <code>custom_auth</code> is set, <em>every</em> request goes through your function, including the <code>sk-</code>
virtual keys from the rest of the gateway series and the master key itself. A handler that only
understands JWTs locks you out of your own gateway. The handler below falls back to the master
key deliberately.</p><p>Back up before you start: <code>cp config.yaml config.yaml.bak</code></p></div></div>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/llm-gateway/jwt_auth.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> jwt</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastapi </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Request</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> litellm</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">proxy</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">_types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> UserAPIKeyAuth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">JWKS_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"JWT_PUBLIC_KEY_URL"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ISSUER </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"JWT_ISSUER"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AUDIENCE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"JWT_AUDIENCE"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">REQUIRED_SCOPE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"gateway:invoke"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MASTER_KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"LITELLM_MASTER_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">_jwks </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> jwt</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">PyJWKClient</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">JWKS_URL</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">user_api_key_auth</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">request</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Request</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> api_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> UserAPIKeyAuth</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    token </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> api_key</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">removeprefix</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"Bearer "</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic"># Not a JWT: fall back to the master key so existing tooling keeps working.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> token</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">count</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> token </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> MASTER_KEY</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> UserAPIKeyAuth</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">token</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">raise</span><span class="token plain"> Exception</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"not a JWT and not the master key"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    signing_key </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> _jwks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get_signing_key_from_jwt</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">token</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    claims </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> jwt</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">decode</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        token</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        signing_key</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">key</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        algorithms</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"RS256"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        issuer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ISSUER</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        audience</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">AUDIENCE</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> REQUIRED_SCOPE </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> claims</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scope"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">raise</span><span class="token plain"> Exception</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"token is missing scope </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">REQUIRED_SCOPE</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> UserAPIKeyAuth</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">token</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> user_id</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">claims</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"sub"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>PyJWKClient</code> does the part that looks hard. It fetches the JWKS, matches the token's <code>kid</code>
header to the right key, and caches it — so after the first request, verification is local
arithmetic. <code>jwt.decode</code> checks the signature, the issuer, the audience and the expiry in one
call, and raises if any of them is wrong.</p>
<p>Three environment variables, appended to <code>~/llm-gateway/.env</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">JWT_PUBLIC_KEY_URL</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">http://10.80.4.212:9100/application/o/agent-gateway/jwks/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">JWT_ISSUER</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">http://10.80.4.212:9100/application/o/agent-gateway/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">JWT_AUDIENCE</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ</span><br></div></code></pre></div></div>
<p><code>JWT_ISSUER</code> and <code>JWT_AUDIENCE</code> must match the <code>iss</code> and <code>aud</code> you saw in the decoded token
exactly, or every token is refused.</p>
<p>Mount the module the same way the gateway already mounts <code>scrubber.py</code>, in
<code>docker-compose.yml</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./jwt_auth.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/jwt_auth.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div></code></pre></div></div>
<p>And in <code>config.yaml</code>, under <code>general_settings</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">custom_auth</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> jwt_auth.user_api_key_auth</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">custom_auth_run_common_checks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><br></div></code></pre></div></div>
<p>That second line matters more than it looks. Without it, LiteLLM warns at startup:</p>
<p><span class="zoomImage__wrap"><img alt="The LiteLLM startup log, carrying the custom_auth_run_common_checks warning among the model registration warnings, ending in Application startup complete" src="https://development-wec.wiline.com/docs/assets/images/h5-custom-auth-warning-863f445ce677a95c6cf806de7ecfc4c7.png" width="942" height="972" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<blockquote>
<p><em>"custom_auth is configured but 'custom_auth_run_common_checks' is not set. Problem: budgets,
model-access allowlists, and per-model rate limits configured on your DB team/project records
will NOT be enforced for custom-auth requests."</em></p>
</blockquote>
<p>Read that carefully. Adding token authentication would, by default, have exempted every token
holder from the controls you already configured. You would have made the gateway more secure in
one dimension and quietly weaker in another, and the only notice is one warning line at
startup.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Editing a mounted config does not restart anything</div><div class="admonitionContent_BuS1"><p><code>config.yaml</code> is a bind mount. Changing its contents changes no part of the compose spec, so
<code>docker compose up -d</code> reports <code>Container llm-gateway  Running</code> and does nothing. Your tests
then run against the old process and reproduce the old results exactly, which reads like your
change had no effect.</p><p>Use <code>docker restart llm-gateway</code>, and confirm by the warning above <em>disappearing</em> from the log.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--the-boundary-in-three-status-codes">Step 5 — The boundary, in three status codes<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#step-5--the-boundary-in-three-status-codes" class="hash-link" aria-label="Direct link to Step 5 — The boundary, in three status codes" title="Direct link to Step 5 — The boundary, in three status codes" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">TOKEN</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/agent-auth/token.sh gateway:invoke</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">NOSCOPE</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/agent-auth/token.sh</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'no token      HTTP %{http_code}\n'</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'wrong scope   HTTP %{http_code}\n'</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$NOSCOPE</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'valid token   HTTP %{http_code}\n'</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$TOKEN</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">no token      HTTP 401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wrong scope   HTTP 401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">valid token   HTTP 429</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Three curl calls printing no token HTTP 401, wrong scope HTTP 401, and valid token HTTP 429" src="https://development-wec.wiline.com/docs/assets/images/h5-three-status-codes-0db858ce4b80dda45f7c2626ec09529c.png" width="804" height="293" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The first two never reach a model. The second is the one that matters: a <strong>valid, correctly
signed, unexpired token from the right issuer</strong>, refused because it did not carry
<code>gateway:invoke</code>. Identity alone is not authority.</p>
<p>The third is the interesting one, and it is not a 200. It cleared authentication and scope, and
was then answered by a completely different part of the gateway — the budget and routing layer
the <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">gateway series</a> configures, which on this box
had a cap already reached. That is the point of the shape rather than a flaw in it: a 401 means
<em>we do not know who you are, or you may not do this</em>, and anything else means the request got
past identity entirely and is now somebody else's decision. Authentication's job ends at the
first two lines.</p>
<p>Confirm the refusal came from your check rather than something incidental:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs llm-gateway </span><span class="token parameter variable" style="color:#36acaa">--since</span><span class="token plain"> 10m </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-iE</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'missing scope|401 Unauthorized'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">user_api_key_auth(): Exception occured - token is missing scope gateway:invoke</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Exception: token is missing scope gateway:invoke</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     "POST /v1/chat/completions HTTP/1.1" 401 Unauthorized</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The gateway log showing the exception token is missing scope gateway followed by a 401 Unauthorized response" src="https://development-wec.wiline.com/docs/assets/images/h5-scope-refusal-log-aec61cc01ab970a987dee6c534e39c59.png" width="939" height="152" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Note where that reason lives. The caller receives a bare 401 with no explanation, which is
correct — telling a client <em>"your token is valid but lacks scope X"</em> tells an attacker exactly
what to go and get. It does mean that debugging this always means reading the gateway log, and
never the response body.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can give a headless agent its own identity: a confidential client restricted to one grant,
a scope that means something, and a gateway that verifies the signature locally and refuses
anything without the scope — with the refusal proven from the log rather than assumed from a
status code.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="invalid_client"><code>invalid_client</code><a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#invalid_client" class="hash-link" aria-label="Direct link to invalid_client" title="Direct link to invalid_client" translate="no">​</a></h3>
<p>The credentials did not authenticate at all. Before blaming authentik, check what actually
landed in your <code>.env</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">awk</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-F</span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{printf "%-18s %3d chars\n", $1, length($2)}'</span><span class="token plain"> ~/agent-auth/.env</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">AK_TOKEN_URL        48 chars</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AK_CLIENT_ID        40 chars</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AK_CLIENT_SECRET   128 chars</span><br></div></code></pre></div></div>
<p>The client ID is 40 characters and the secret is 128. A zero-length value means the file is the
problem, not the provider.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="invalid_grant-with-everything-apparently-correct"><code>invalid_grant</code>, with everything apparently correct<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#invalid_grant-with-everything-apparently-correct" class="hash-link" aria-label="Direct link to invalid_grant-with-everything-apparently-correct" title="Direct link to invalid_grant-with-everything-apparently-correct" translate="no">​</a></h3>
<p>The provider is not paired to an application. See Step 2 — the Providers list flags it, the
error text does not.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="ak_token_url-unbound-variable-and-a-corrupted-secret"><code>AK_TOKEN_URL: unbound variable</code>, and a corrupted secret<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#ak_token_url-unbound-variable-and-a-corrupted-secret" class="hash-link" aria-label="Direct link to ak_token_url-unbound-variable-and-a-corrupted-secret" title="Direct link to ak_token_url-unbound-variable-and-a-corrupted-secret" translate="no">​</a></h3>
<p><code>echo 'X=1' &gt;&gt; .env</code> on a file whose last line has no trailing newline appends to that line
instead of creating a new one:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">AK_CLIENT_SECRET=p94oDU...dgJcAK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/</span><br></div></code></pre></div></div>
<p>Two variables destroyed in one command, silently — the secret is now wrong <em>and</em> the new
variable does not exist. Check with <code>cat -A</code>, which marks line ends with <code>$</code>, and prefer
writing the whole file with a single heredoc over appending to it.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-config-change-had-no-effect">The config change had no effect<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#the-config-change-had-no-effect" class="hash-link" aria-label="Direct link to The config change had no effect" title="Direct link to The config change had no effect" translate="no">​</a></h3>
<p>The container was never restarted. See the warning in Step 4.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="401-with-no-reason-in-the-response"><code>401</code> with no reason in the response<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#401-with-no-reason-in-the-response" class="hash-link" aria-label="Direct link to 401-with-no-reason-in-the-response" title="Direct link to 401-with-no-reason-in-the-response" translate="no">​</a></h3>
<p>By design. The reason is in <code>docker logs llm-gateway</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: a machine identity issued by your identity provider rather than minted by hand, a
credential that expires in five minutes, a scope that has to be requested and is checked on
every call, revocation that happens in one place, and a caller the gateway can name in its logs
as something other than a key prefix.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>The MCP server is still open.</strong> Everything here protects the gateway. The tools server from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/">agent orchestration Part 4</a> still trusts anything that
can reach its port, <code>issue_refund</code> included. Same problem, one layer up, and the subject of
the next part.</li>
<li class=""><strong>One scope, one client.</strong> A real deployment has several agents with different scopes, and
<code>scope_mappings</code> between scopes and models is where that goes.</li>
<li class=""><strong>The master key still works</strong>, deliberately, as the fallback in the handler. It remains the
break-glass credential Part 4 described, and it still bypasses everything.</li>
<li class=""><strong>Still plain HTTP.</strong> The token crosses the network in the clear, and a token in the clear is
a key in the clear until it expires.</li>
<li class=""><strong>The service account is invisible.</strong> authentik created
<code>ak-agent-gateway-client_credentials</code> without asking, and nothing in the token says it is a
machine rather than a person.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>An identity for the agent, not a key</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The MCP server. FastMCP ships a <code>JWTVerifier</code> that takes a <code>jwks_uri</code>, an <code>issuer</code>, an
<code>audience</code> and <code>required_scopes</code> — the same four things configured here — so the tools server
can demand a scoped token before it will list a tool, let alone run one. That closes the gap
agent orchestration Part 4 left open, where scoping happened in the client and the server
trusted whoever reached the port.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.goauthentik.io/docs/providers/oauth2/client_credentials" target="_blank" rel="noopener noreferrer" class="">authentik — client credentials</a></li>
<li class=""><a href="https://docs.goauthentik.io/docs/add-secure-apps/providers/property-mappings/" target="_blank" rel="noopener noreferrer" class="">authentik — provider property mappings, and scope mappings with OAuth2</a></li>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/custom_auth" target="_blank" rel="noopener noreferrer" class="">LiteLLM — custom auth</a></li>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/token_auth" target="_blank" rel="noopener noreferrer" class="">LiteLLM — OIDC JWT-based auth</a>, the Enterprise path</li>
<li class=""><a href="https://pyjwt.readthedocs.io/en/stable/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint" target="_blank" rel="noopener noreferrer" class="">PyJWT — <code>PyJWKClient</code></a></li>
<li class=""><a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4" target="_blank" rel="noopener noreferrer" class="">RFC 6749 §4.4 — the client credentials grant</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>oauth2</category>
            <category>oidc</category>
            <category>authentik</category>
            <category>identity</category>
            <category>litellm</category>
            <category>gateway</category>
            <category>agents</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Split one MCP toolbox between two agents so the scheduler cannot issue refunds]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/</guid>
            <pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 3 handed one agent all five tools and let a human pause guard the dangerous one. That guard depends on the model asking. This splits the same MCP server between a scheduler and a billing agent, so the scheduler cannot refund an invoice for the simplest reason available — the tool was never in its list. Then the supervisor goes back on top, and routing turns out not to be the guarantee.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__mcp" src="data:image/png;base64,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" alt="Model Context Protocol"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="487" height="249" fill="none" viewBox="0 0 487 249" class="tutorialHero__brand"><path fill="#1C3C3C" fill-rule="evenodd" d="M124.273.412h239.031c68.111 0 123.52 55.603 123.52 123.948s-55.409 123.948-123.52 123.948H124.273c-68.11 0-123.52-55.603-123.52-123.948S56.164.412 124.274.412M234.018 192.55c3.001 3.156 7.443 3 11.378 2.182l.039.02c1.827-1.486-.77-3.368-3.249-5.165-1.487-1.077-2.932-2.125-3.356-3.038 1.373-1.674-2.687-5.474-5.847-8.433-1.327-1.242-2.495-2.335-3.037-3.061-2.248-2.448-3.152-5.537-4.062-8.644-.603-2.061-1.208-4.131-2.211-6.027-6.176-14.339-13.248-28.561-23.165-40.699-6.373-8.067-13.646-15.291-20.922-22.518-4.691-4.66-9.382-9.319-13.835-14.206-4.581-4.73-7.338-10.556-10.1-16.392-2.312-4.886-4.627-9.779-8.018-14.04-10.268-15.196-42.686-19.346-47.44 2.124.019.662-.195 1.09-.78 1.52-2.63 1.928-4.968 4.11-6.935 6.76-4.812 6.721-5.553 18.118.448 24.158l.025-.388c.2-3.05.388-5.9 2.8-8.087 4.637 3.994 11.67 5.416 17.047 2.435 6.483 9.3 8.548 20.543 10.621 31.823 1.726 9.398 3.457 18.821 7.751 27.171l.266.443c2.524 4.202 5.089 8.472 8.326 12.142 1.176 1.823 3.59 3.791 6 5.755 3.18 2.591 6.353 5.177 6.663 7.416.015.974.01 1.961.006 2.954-.025 5.879-.051 11.967 3.716 16.801 2.084 4.228-3.02 8.475-7.131 7.949-2.254.312-4.716-.282-7.161-.871-3.346-.807-6.659-1.607-9.36-.064-.758.82-1.846.849-2.94.877-1.297.035-2.601.069-3.373 1.422-.158.402-.528.856-.913 1.328-.846 1.036-1.762 2.159-.665 3.016q.149-.111.294-.224c1.663-1.269 3.248-2.479 5.493-1.724-.299 1.658.772 2.103 1.843 2.547q.281.114.553.239c-.011.385-.087.774-.163 1.159-.18.921-.356 1.824.358 2.62.339-.344.639-.732.939-1.12.735-.95 1.474-1.904 2.802-2.25 2.92 3.9 5.862 2.28 9.554.248 4.164-2.293 9.281-5.111 16.396-1.125-2.727-.136-5.163.195-6.994 2.455-.448.507-.838 1.091-.039 1.754 4.21-2.728 5.961-1.748 7.61-.825 1.19.666 2.326 1.302 4.294.493.465-.242.93-.493 1.396-.745 3.161-1.705 6.366-3.434 10.118-2.839-2.803.808-3.8 2.584-4.888 4.524-.539.959-1.099 1.957-1.911 2.898-.429.429-.624.936-.137 1.656 5.869-.488 8.087-1.976 11.083-3.986 1.429-.959 3.036-2.038 5.302-3.183 2.505-1.542 5.009-.556 7.436.4 2.633 1.037 5.175 2.037 7.527-.264.743-.7 1.674-.708 2.602-.717a10 10 0 0 0 1.002-.043c-.732-3.92-4.861-3.874-9.052-3.827-4.847.054-9.778.109-9.632-5.972 4.504-3.076 4.546-8.415 4.585-13.461.01-1.218.019-2.419.091-3.567 3.313 1.847 6.817 3.291 10.299 4.725 3.276 1.35 6.533 2.692 9.593 4.354 3.195 5.143 8.182 11.962 14.826 11.514.175-.526.331-.974.526-1.5.383.067.787.169 1.199.273 1.743.442 3.611.915 4.509-1.15m130.213-58.45a20.54 20.54 0 0 0 14.504 5.994c5.44 0 10.658-2.156 14.505-5.994a20.44 20.44 0 0 0 6.007-14.469 20.44 20.44 0 0 0-6.007-14.469 20.54 20.54 0 0 0-21.882-4.624l-11.757-17.162-8.194 5.614 11.818 17.25a20.43 20.43 0 0 0-5.002 13.391 20.43 20.43 0 0 0 6.008 14.469m-36.808-55.576a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.784 20.47 20.47 0 0 0-7.873-10.09 20.557 20.557 0 0 0-18.35-2.282 20.5 20.5 0 0 0-7.964 5.175 20.44 20.44 0 0 0-4.77 8.201 20.429 20.429 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m0 118.824a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.783 20.47 20.47 0 0 0-7.873-10.092 20.55 20.55 0 0 0-26.314 2.894 20.45 20.45 0 0 0-4.77 8.201 20.43 20.43 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m18.857-72.629v-10.174h-31.394a20.3 20.3 0 0 0-4.398-8.342L322.3 88.704l-8.59-5.698-11.812 17.499a20.5 20.5 0 0 0-6.749-1.221 20.5 20.5 0 0 0-14.462 5.959 20.3 20.3 0 0 0-5.99 14.388 20.3 20.3 0 0 0 5.99 14.387 20.5 20.5 0 0 0 14.462 5.96 20.5 20.5 0 0 0 6.749-1.221l11.812 17.498 8.487-5.697-11.709-17.498a20.3 20.3 0 0 0 4.398-8.342z" clip-rule="evenodd"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 4 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->4</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->4<!-- --> earned</span></div><div class="skillTracker__series">Agent orchestration with LangGraph</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">State that survives a restart</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Hand work between agents</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Governed tools an agent can call</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">Engineer the context, not the prompt</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">Part 3</a> gave an agent five tools over MCP —
a calendar, a customer list, invoices, and a refund — and put a human in front of the refund.
It ended by admitting the obvious: <strong>one agent held all five.</strong> Nothing stopped the model
reaching for <code>issue_refund</code> when it had been asked to book an appointment. The human pause was
the only thing in the way, and a pause only fires if the model calls the tool at all.</p>
<p>This part removes the tool instead. The scheduler gets three tools and the billing agent gets
three, sharing one. Ask the scheduler to refund an invoice and it cannot — not because it was
told not to, not because a policy blocked it, but because <code>issue_refund</code> was never in the list
it was given.</p>
<p>Then <a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/">Part 2's supervisor</a> goes back on top,
and the interesting property falls out: <strong>routing decides who works, scoping decides what is
possible.</strong> A misrouted refund still cannot refund.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>The same box, server and database as <a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">Part 3</a>:
one WEC Instance, Ubuntu 22.04, Python 3.10, <code>langchain</code> 1.4.0, <code>langgraph</code> 1.2.11, <code>mcp</code> 2.1.1,
<code>fastmcp</code> 4.0.2. <code>office_tools.py</code> unchanged and still serving five tools on
<code>127.0.0.1:8770</code>. Models through the LiteLLM gateway to WEC Inference. Reset the database with
<code>./.venv/bin/python seed.py</code> before following along, so invoice 2 is open again.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-scoping-actually-means-here">What "scoping" actually means here<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#what-scoping-actually-means-here" class="hash-link" aria-label="Direct link to What &quot;scoping&quot; actually means here" title="Direct link to What &quot;scoping&quot; actually means here" translate="no">​</a></h2>
<p>There is no new mechanism in this post. <code>create_agent</code> takes a list of tools; we hand it a
shorter list. That is the entire technique, and it is worth being precise about why it is
stronger than the alternatives:</p>
<ul>
<li class=""><strong>A system prompt</strong> telling the model not to issue refunds is a request. It survives exactly
as long as the model cooperates.</li>
<li class=""><strong>A policy check inside the tool</strong> is real, but it runs <em>after</em> the model decided to call it,
and it has to be written into every tool you ever add.</li>
<li class=""><strong>Not passing the tool</strong> removes the option from the model's context. There is nothing to
refuse, because there is nothing to call.</li>
</ul>
<p>The last one is the only one that doesn't depend on inference going well.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-server-two-toolboxes">One server, two toolboxes<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#one-server-two-toolboxes" class="hash-link" aria-label="Direct link to One server, two toolboxes" title="Direct link to One server, two toolboxes" translate="no">​</a></h2>
<!-- -->
<p>The server offers all five to anyone who asks. What differs is which ones each agent is
<strong>handed</strong>. <code>find_customer</code> goes to both, because looking a customer up is harmless.</p>
<p>The blocked arrow is the whole post. The scheduler cannot call <code>issue_refund</code> — not because a
rule forbids it, but because the tool is not in the list it was given, so it never reaches the
model's context at all.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">The MCP server, virtualenv and seeded database from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">Part 3</a></li>
<li class="">The gateway key in <code>~/mcp-tools/.env</code>, as Part 3 set up</li>
<li class="">Python 3.10+</li>
</ul>
<p>Part 3 ran the server in the foreground, which cost a terminal and died with the SSH session.
Start it detached instead — one session is enough for everything below:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">nohup</span><span class="token plain"> ./.venv/bin/python office_tools.py </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> server.log </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8770</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--two-toolsets-from-one-server">Step 1 — Two toolsets from one server<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#step-1--two-toolsets-from-one-server" class="hash-link" aria-label="Direct link to Step 1 — Two toolsets from one server" title="Direct link to Step 1 — Two toolsets from one server" translate="no">​</a></h2>
<p>The server still offers five tools. The roles name subsets of them:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/scoped.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> asyncio</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> sys</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">agents </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> create_agent</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> MCPAdapter</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain_openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> ChatOpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">checkpoint</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">memory </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> InMemorySaver</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MCP_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://127.0.0.1:8770/mcp"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ROLES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"find_customer"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"list_availability"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"book_slot"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">   </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"find_customer"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"open_invoices"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"issue_refund"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">model </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:4000/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"GATEWAY_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"qwen-mid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    role</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> request </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> MCPAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">MCP_URL</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        catalog </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">t</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> t </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> t </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">list_tools</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"server offers : </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation builtin">sorted</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">catalog</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        allowed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">catalog</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">n</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> n </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> ROLES</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">role</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> can see : </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation">t</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">name </span><span class="token string-interpolation interpolation keyword" style="color:#00009f">for</span><span class="token string-interpolation interpolation"> t </span><span class="token string-interpolation interpolation keyword" style="color:#00009f">in</span><span class="token string-interpolation interpolation"> allowed</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">\n"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        agent </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> create_agent</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">model</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> allowed</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">InMemorySaver</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        cfg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">role</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">-1"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> agent</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ainvoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> request</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cfg</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            req </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">value</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"requests"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"PAUSED — the server is asking a human:\n  </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">req</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'message'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"--- answer ---"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>checkpointer</code> is carried over from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/">Part 3</a>: <code>issue_refund</code> pauses the run with an
interrupt, and an interrupted run needs somewhere to wait. The scheduler never triggers one, but
both roles run the same code.</p>
<p>The whole of the technique is one line:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">allowed </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">catalog</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">n</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> n </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> ROLES</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p><code>find_customer</code> appears in both roles — looking a customer up is harmless and both jobs need
it. <code>book_slot</code> and <code>issue_refund</code> appear in exactly one each.</p>
<p>The script prints both lists on every run for a reason. <code>server offers</code> is the full catalog the
MCP server returned; <code>can see</code> is what we passed to <code>create_agent</code>. Keeping them side by side
makes it obvious that <strong>the server hid nothing</strong> — the narrowing happened in the client, on the
line above.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--the-same-sentence-to-both-specialists">Step 2 — The same sentence, to both specialists<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#step-2--the-same-sentence-to-both-specialists" class="hash-link" aria-label="Direct link to Step 2 — The same sentence, to both specialists" title="Direct link to Step 2 — The same sentence, to both specialists" translate="no">​</a></h2>
<p>Ask the billing agent to do its job:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python scoped.py billing </span><span class="token string" style="color:#e3116c">"Refund Maria's open invoice."</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The billing agent holding find_customer, open_invoices and issue_refund, pausing with the server&amp;#39;s confirmation question about invoice 2" src="https://development-wec.wiline.com/docs/assets/images/lg4-billing-paused-6de8f7f7d8c481db7073918444d88e53.png" width="798" height="204" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>It found Maria, resolved "open invoice" to invoice 2, called <code>issue_refund</code>, and stopped —
because the tool demands a human. That is Part 3's guard, working.</p>
<p>Now the identical sentence to the wrong specialist:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python scoped.py scheduler </span><span class="token string" style="color:#e3116c">"Refund Maria's open invoice."</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The scheduler agent holding find_customer, list_availability and book_slot, replying that it has no tools for refunds or invoices" src="https://development-wec.wiline.com/docs/assets/images/lg4-scheduler-refused-080822f69c551c7655e8e5fa5093178b.png" width="800" height="319" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<blockquote>
<p>I don't have access to tools that can process refunds or handle invoices. The available tools
I have are for finding customers, listing appointment availability, and booking appointment
slots.</p>
</blockquote>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The politeness is not the guarantee</div><div class="admonitionContent_BuS1"><p>That answer is accurate and well-mannered, and it would be a mistake to read it as the model
behaving well. The model <strong>could not</strong> have called <code>issue_refund</code>. It was never in the list, so
it was never in the context, so there was no call to decline.</p><p>The distinction matters because a model that <em>does</em> hold a dangerous tool can also produce a
polite sentence — including one describing an action it never took. We documented exactly that
<a class="" href="https://development-wec.wiline.com/docs/news/langchain-mcp-first-class/">in the news piece</a>: an era error handed to the model, and the
model narrating an approval nobody would ever be asked for. A refusal you can trust is one the
model had no way around.</p></div></div>
<p>And the scheduler doing the job it <em>does</em> have tools for, so the restriction is targeted rather
than crippling:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python scoped.py scheduler </span><span class="token string" style="color:#e3116c">"Book Maria into a free slot on 2026-09-05"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The scheduler agent booking Maria into the 09:00 slot on 2026-09-05" src="https://development-wec.wiline.com/docs/assets/images/lg4-scheduler-books-56592bddc8b3d38f6f2f0f8d8aaf64ec.png" width="801" height="226" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--check-that-nothing-moved">Step 3 — Check that nothing moved<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#step-3--check-that-nothing-moved" class="hash-link" aria-label="Direct link to Step 3 — Check that nothing moved" title="Direct link to Step 3 — Check that nothing moved" translate="no">​</a></h2>
<p>A refusal is a sentence. The database is the evidence.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import sqlite3</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for r in sqlite3.connect('office.db').execute('select id, customer_id, amount, status from invoices'): print(r)"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The invoices table showing invoice 2 still open after both the refusal and the paused run" src="https://development-wec.wiline.com/docs/assets/images/lg4-nothing-happened-3c326c0194b72bcc27c1a87d9a2d05f7.png" width="790" height="145" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Invoice 2 is still <code>open</code>, for two different reasons: the scheduler had no tool to change it,
and billing was stopped by the human gate before it could. Neither depended on the model
choosing well.</p>
<p>It is worth seeing the same agent report a boring truth, too. Before we reset the database,
invoice 2 had already been refunded in Part 3 — and asked to refund it again, billing looked,
found nothing open, and said so:</p>
<p><span class="zoomImage__wrap"><img alt="The billing agent reporting that Maria has no open invoices, her invoices being either paid or already refunded" src="https://development-wec.wiline.com/docs/assets/images/lg4-billing-nothing-open-b4f526ef9e6865039b0aefe2909f7918.png" width="799" height="207" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>No invented invoice, no cheerful confirmation. That is the behaviour that makes its other
answers worth anything, and it is not something to take on trust — it is something to check,
which is why every claim in this post has a query behind it.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can give two agents different slices of the same MCP server, so that a capability one of
them must never have is absent from its context rather than forbidden by instruction — and
verify in the data that the absence held.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--the-supervisor-back-on-top">Step 4 — The supervisor, back on top<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#step-4--the-supervisor-back-on-top" class="hash-link" aria-label="Direct link to Step 4 — The supervisor, back on top" title="Direct link to Step 4 — The supervisor, back on top" translate="no">​</a></h2>
<p>Part 2 routed work between a scheduler and a billing agent that had no tools. Now they do.</p>
<p><code>supervisor.py</code> is <code>scoped.py</code> with Part 2's graph around it: the same <code>ROLES</code>, <code>MCPAdapter</code> and
<code>ChatOpenAI</code> setup, plus a <code>State</code> TypedDict, a <code>StateGraph</code>, and one agent built per role
inside the <code>async with</code> block — <code>agents = {role: create_agent(model, [catalog[n] for n in names]) for role, names in ROLES.items()}</code>. Only the parts that are new to this post are shown here.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/supervisor.py (the parts that matter)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">BILLING_WORDS </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"invoice"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"refund"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"bill"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"charge"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"payment"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">supervisor</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    nxt </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">any</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">w </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"request"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> w </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> BILLING_WORDS</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor routes to </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">nxt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor-&gt;</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> agents</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ainvoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"request"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        q </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">value</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"requests"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"answer"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"PAUSED — </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">q</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"answer"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">role</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">scheduler_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">billing_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>Command[Literal["scheduler", "billing"]]</code> annotation is doing the same work it did in Part
2 — it is the only declaration of where the supervisor may send work, and dropping it makes
every graph diagram lie.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python supervisor.py </span><span class="token string" style="color:#e3116c">"Refund Maria's open invoice."</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The supervisor routing a refund request to the billing agent, which pauses on the human gate" src="https://development-wec.wiline.com/docs/assets/images/lg4-supervisor-refund-eff231a6a04d02fc5430fa25025750e6.png" width="801" height="226" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python supervisor.py </span><span class="token string" style="color:#e3116c">"Book Maria into a free slot on 2026-09-04"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The supervisor routing a booking request to the scheduler agent, which books the slot" src="https://development-wec.wiline.com/docs/assets/images/lg4-supervisor-book-cb9829d31562e18281b2165e7fb3bf6e.png" width="800" height="244" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Routing is not the guarantee</div><div class="admonitionContent_BuS1"><p>That supervisor is a keyword match. It is trivially foolable — "settle Maria's outstanding
amount" contains none of <code>BILLING_WORDS</code> and lands on the scheduler.</p><p>Which is the point worth taking away. <strong>A misrouted refund still cannot refund.</strong> The router
decides who gets the work; the toolsets decide what that agent can do with it. If routing were
the only control, every bug in the supervisor would be a security bug. With scoping underneath,
a routing mistake is just a bad answer.</p></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can put a router in front of scoped specialists and reason clearly about which failures are
dangerous — because the blast radius of a routing bug is bounded by what the receiving agent
was given, not by what it was asked to do.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="invalidupdateerror-expected-dict-got-coroutine-object"><code>InvalidUpdateError: Expected dict, got &lt;coroutine object&gt;</code><a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#invalidupdateerror-expected-dict-got-coroutine-object" class="hash-link" aria-label="Direct link to invalidupdateerror-expected-dict-got-coroutine-object" title="Direct link to invalidupdateerror-expected-dict-got-coroutine-object" translate="no">​</a></h3>
<p>The node is an <code>async</code> function wrapped in a <code>lambda</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">lambda</span><span class="token plain"> s</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> s</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># returns a coroutine</span><br></div></code></pre></div></div>
<p>LangGraph calls it, gets a coroutine back instead of a state update, and raises. The real clue
is the last line of the output — <code>RuntimeWarning: coroutine 'run' was never awaited</code> — which is
the part nobody reads. Define proper <code>async def</code> nodes instead.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="runtimeerror-client-failed-to-connect-all-connection-attempts-failed"><code>RuntimeError: Client failed to connect: All connection attempts failed</code><a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#runtimeerror-client-failed-to-connect-all-connection-attempts-failed" class="hash-link" aria-label="Direct link to runtimeerror-client-failed-to-connect-all-connection-attempts-failed" title="Direct link to runtimeerror-client-failed-to-connect-all-connection-attempts-failed" translate="no">​</a></h3>
<p>Nothing is listening on 8770. If you started the MCP server in a foreground shell, it died with
your SSH session. The error names the <em>client</em>, which sends you looking in the wrong place.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8770</span><br></div></code></pre></div></div>
<p>Start it with <code>nohup ... &amp;</code> so it survives, and check the port before blaming the code.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-backgrounded-run-prints-nothing-for-minutes">A backgrounded run prints nothing for minutes<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#a-backgrounded-run-prints-nothing-for-minutes" class="hash-link" aria-label="Direct link to A backgrounded run prints nothing for minutes" title="Direct link to A backgrounded run prints nothing for minutes" translate="no">​</a></h3>
<p>Python buffers stdout when it is redirected to a file, so a long run looks identical to a hang.
Add <code>-u</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">nohup</span><span class="token plain"> ./.venv/bin/python </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> supervisor.py </span><span class="token string" style="color:#e3116c">"..."</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> run.log </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> run.log</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="keyerror-gateway_key"><code>KeyError: 'GATEWAY_KEY'</code><a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#keyerror-gateway_key" class="hash-link" aria-label="Direct link to keyerror-gateway_key" title="Direct link to keyerror-gateway_key" translate="no">​</a></h3>
<p><code>source .env</code> sets shell variables, not environment variables, and <code>export</code> does not cross SSH
sessions. Every command that starts the agent needs its own
<code>set -a &amp;&amp; source .env &amp;&amp; set +a</code> — including the backgrounded ones.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: two agents drawing different subsets of one MCP server, a capability that is absent
rather than forbidden, and a router whose mistakes cost correctness instead of money.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>The MCP server still trusts everyone.</strong> Scoping happens in the <em>client</em>. Anything that can
reach <code>127.0.0.1:8770</code> can call <code>issue_refund</code> directly, agent or not. This is a control on
what your agents can do, not on what your server will accept.</li>
<li class=""><strong>No identity on the tool call.</strong> The server cannot tell the scheduler from the billing agent,
because nothing in the protocol says which is calling.</li>
<li class=""><strong>The subsets are hardcoded.</strong> A real deployment reads them from the same place it reads team
membership — which is the <a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">hardening series'</a>
territory, one layer down.</li>
<li class=""><strong>The supervisor is a keyword match</strong>, kept deliberately dumb so the scoping argument stands
on its own.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Engineer the context, not the prompt</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Push the boundary from the client to the server: <strong>authenticate the tool call</strong>, so the server
knows which agent is asking and refuses <code>issue_refund</code> to anything that isn't billing. That is
the same question <a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">Part 4 of the hardening series</a>
asked about the gateway's own API — a control plane and a data plane, one layer apart — and
FastMCP already supports bearer tokens and OAuth 2.1 for it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.langchain.com/oss/python/langchain/mcp" target="_blank" rel="noopener noreferrer" class="">MCP in LangChain</a> — connections, auth, elicitation</li>
<li class=""><a href="https://gofastmcp.com/clients/auth/oauth" target="_blank" rel="noopener noreferrer" class="">FastMCP — client authentication</a></li>
<li class=""><a href="https://docs.langchain.com/oss/python/langgraph/graph-api" target="_blank" rel="noopener noreferrer" class="">LangGraph — <code>Command</code> and conditional edges</a></li>
<li class=""><a href="https://modelcontextprotocol.io/specification/2026-07-28" target="_blank" rel="noopener noreferrer" class="">MCP <code>2026-07-28</code> specification</a></li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>agents</category>
            <category>langgraph</category>
            <category>langchain</category>
            <category>mcp</category>
            <category>tools</category>
            <category>security</category>
            <category>self-hosting</category>
            <category>wec</category>
            <category>wec-inference</category>
        </item>
        <item>
            <title><![CDATA[The binding that wasn't there: group access control, and what SSO doesn't protect]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/</guid>
            <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 3 shipped SSO with empty bindings. Closing that gap on the LiteLLM gateway turned up something worse: authentik's wizard let us configure the bindings, showed them in its own table, and saved none of them — no error, no warning. Then, once access control actually worked, the API answered a request from a shell with no account, no session and no membership. Both of those are the post.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__authentik" src="data:image/png;base64,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" alt="authentik"><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">4</span><span class="skillTracker__skill" data-state="current">Membership, not just an account</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/">Part 3</a> put authentik in front of Langfuse and
ended with an admission: the bindings step was left empty, so every account in authentik
could reach Langfuse and nothing would warn you.</p>
<p>This part closes that gap on the <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">LiteLLM gateway</a>
— and the closing went wrong in a way worth more than the procedure. We configured the
bindings in authentik's wizard, watched them appear in the wizard's own table, submitted,
and ended up with <strong>zero bindings saved</strong>. The application was open to everyone, the UI said
nothing, and the only reason we found out is that we tested with an account that should have
been refused and wasn't.</p>
<p>Then, once access control genuinely worked, we called the gateway's API from a shell with no
account, no session and no group. It answered normally. That one isn't a bug — it's the
difference between a <strong>control plane</strong> and a <strong>data plane</strong>, and assuming SSO covers both is
how people conclude they've secured something they haven't.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15.
authentik <code>2026.8.1</code>, the same instance from Part 3 on host port <code>9100</code>. LiteLLM
<code>main-stable</code> from <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 of the gateway series</a>,
bound to <code>127.0.0.1:4000</code>, models served over WEC Inference.</p><p>Plain HTTP on a private LAN, as in Part 3.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-new-here">What's new here<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#whats-new-here" class="hash-link" aria-label="Direct link to What's new here" title="Direct link to What's new here" translate="no">​</a></h2>
<p>Part 3 taught the wizard — application, provider, redirect URI, scopes. That isn't repeated;
<a class="" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/">it's all in Part 3</a> and the steps are identical for
any app. Three things are new:</p>
<p><strong>A group</strong>, as the unit you grant access to, so that adding somebody to a team and giving
them access are the same action.</p>
<p><strong>A binding</strong>, which attaches that group to an application, so reaching it requires
membership rather than merely existing.</p>
<p><strong>A boundary</strong>, demonstrated rather than asserted: what SSO protects, and what it doesn't
touch at all.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A working authentik, ideally the one from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/">Part 3</a></li>
<li class="">A running LiteLLM gateway, from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 of the gateway series</a></li>
<li class="">Its <code>LITELLM_MASTER_KEY</code>, which you'll need to create a virtual key</li>
<li class="">SSH access to the box, because the gateway is bound to <code>127.0.0.1</code></li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-doors-one-of-them-guarded">Two doors, one of them guarded<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#two-doors-one-of-them-guarded" class="hash-link" aria-label="Direct link to Two doors, one of them guarded" title="Direct link to Two doors, one of them guarded" translate="no">​</a></h2>
<!-- -->
<p>Two boxes, no arrow between them — that's the point. Everything this post configures lives
in the left one. The right one never consults authentik at all.</p>
<p>There is also a third way in that the diagram deliberately leaves out, because it belongs to
neither plane: LiteLLM's <strong>master key</strong> logs into the Admin UI directly, skipping the group
check entirely. We hit it in Step 5.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--a-group-and-two-accounts">Step 1 — A group, and two accounts<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#step-1--a-group-and-two-accounts" class="hash-link" aria-label="Direct link to Step 1 — A group, and two accounts" title="Direct link to Step 1 — A group, and two accounts" translate="no">​</a></h2>
<p><strong>Directory → Groups → New Group</strong>, name it <code>gateway-admins</code>, and leave <strong>Superuser
privileges</strong> off. That flag makes members administrators <em>of authentik itself</em> and has
nothing to do with which applications they can reach.</p>
<p>authentik ships three groups already — <code>authentik Admins</code>, <code>authentik Agent-Users</code>,
<code>authentik Read-only</code> — and <code>authentik Admins</code> already contains your <code>akadmin</code>. Binding that
one instead would work today and quietly mean "anyone I ever make an authentik admin," which
is not the same intent.</p>
<p><span class="zoomImage__wrap"><img alt="authentik&amp;#39;s Groups list showing only the three built-in groups, with authentik Admins already holding one member" src="https://development-wec.wiline.com/docs/assets/images/ak-groups-list-a17357dbbd4468da8f7232d5487ef77a.png" width="2788" height="1334" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Add <code>akadmin</code> to <code>gateway-admins</code>. Then <strong>Directory → Users → New User</strong>, type <strong>Internal</strong>,
username <code>contractor</code>, and <strong>no groups at all</strong>.</p>
<p>Internal rather than External so that the only difference between the two accounts is
membership — any other difference muddies the result. Not a Service Account: those are
machine-to-machine and don't do interactive logins.</p>
<p>authentik creates users without a password, and the Edit dialog has no password field — it's
a separate action on the user's detail page. From the shell:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import User</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">u = User.objects.get(username='contractor')</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">u.set_password('Contractor123!')</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">u.save()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('password set for', u.username)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-2</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Silencing <code>ak shell</code></div><div class="admonitionContent_BuS1"><p>Every <code>ak shell</code> call prints roughly sixty lines of bootstrap JSON before your output.
<code>2&gt;/dev/null | tail -N</code> drops the noise and keeps the last N lines. Used throughout below.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--register-the-gateway-and-watch-the-slug">Step 2 — Register the gateway, and watch the slug<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#step-2--register-the-gateway-and-watch-the-slug" class="hash-link" aria-label="Direct link to Step 2 — Register the gateway, and watch the slug" title="Direct link to Step 2 — Register the gateway, and watch the slug" translate="no">​</a></h2>
<p>Run the Part 3 wizard: application <strong>LiteLLM</strong>, provider <strong>OAuth2/OpenID</strong>, authorization
flow <code>default-provider-authorization-explicit-consent</code>, client type <strong>Confidential</strong>.</p>
<p>The gateway is published on <code>127.0.0.1:4000</code>, so your browser can't reach it — and an OIDC
login happens in a browser. Open a tunnel from your workstation:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">ssh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">4000</span><span class="token plain">:127.0.0.1:4000 ubuntu@10.80.4.212</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>ssh -L</code> warns, then carries on without the tunnel</div><div class="admonitionContent_BuS1"><p>If something already holds local port 4000, ssh prints <code>bind: Address already in use</code> and
still gives you a working shell — with a dead forward. The only symptom is
<code>ERR_CONNECTION_REFUSED</code> in the browser, which reads like the gateway being down. Check the
port before blaming the gateway: <code>lsof -nP -iTCP:4000 -sTCP:LISTEN</code>.</p></div></div>
<p>Everything the browser types is now <code>localhost:4000</code>, so that's the redirect URI, mode
<strong>Strict</strong>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">http://localhost:4000/sso/callback</span><br></div></code></pre></div></div>
<p>Now the first surprise. Ask authentik what it actually named things:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for a in Application.objects.all():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(repr(a.name), '-&gt;', repr(a.slug))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">'Langfuse' -&gt; 'langfuse'</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">'LiteLLM' -&gt; 'lite-llm'</span><br></div></code></pre></div></div>
<p><strong><code>lite-llm</code>, not <code>litellm</code>.</strong> The slugifier read the capitals in "LiteLLM" and split them.
Part 3 called the slug load-bearing because it lands in the issuer URL, and here it is:
<code>http://10.80.4.212:9100/application/o/lite-llm/</code>. Set the slug explicitly rather than
letting a CamelCase name generate one, or carry that hyphen in every URL forever.</p>
<p>Read the credentials back through the real slug:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">p = Application.objects.get(slug='lite-llm').get_provider()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('CLIENT_ID    :', p.client_id)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('CLIENT_SECRET:', p.client_secret)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-3</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-bindings-the-wizard-threw-away">Step 3 — The bindings the wizard threw away<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#step-3--the-bindings-the-wizard-threw-away" class="hash-link" aria-label="Direct link to Step 3 — The bindings the wizard threw away" title="Direct link to Step 3 — The bindings the wizard threw away" translate="no">​</a></h2>
<p>The wizard's step 4 is <strong>Configure Bindings</strong>, and Part 3 walked past it showing <code>No bound policies.</code> This time we didn't walk past it. We bound the group there, the wizard listed it
in its table, and we submitted.</p>
<p><span class="zoomImage__wrap"><img alt="The wizard&amp;#39;s Configure Bindings step, empty, reading No bound policies" src="https://development-wec.wiline.com/docs/assets/images/ak-bindings-wizard-empty-996a2adb20faf53dda84769592740e1c.png" width="1800" height="1033" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><span class="zoomImage__wrap"><img alt="The same wizard step after binding, listing Group gateway-admins as enabled" src="https://development-wec.wiline.com/docs/assets/images/ak-bindings-wizard-group-2c88ba715a495db175a3e85cf83df1f9.png" width="1800" height="1025" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Then we asked the database what existed:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">app = Application.objects.get(slug='lite-llm')</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">bs = list(app.bindings.all())</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('binding count:', len(bs))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for b in bs:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print('group', b.group, '| enabled', b.enabled, '| negate', b.negate)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-4</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">binding count: 0</span><br></div></code></pre></div></div>
<p>Zero. The application's own bindings tab agreed — <strong>No Policies bound.</strong> Two independent
sources, one conclusion: the wizard displayed access control that was never written.</p>
<p><span class="zoomImage__wrap"><img alt="The LiteLLM application&amp;#39;s own bindings tab reading No Policies bound" src="https://development-wec.wiline.com/docs/assets/images/ak-bindings-none-71e30bfc7aa65339a119ee1ccb38d3ab.png" width="1896" height="901" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>This is worse than skipping the step</div><div class="admonitionContent_BuS1"><p>Part 3's lesson was "don't skip bindings." The real hazard is that you can <em>complete</em> them,
see your rules in the wizard's table, submit, and end up with an application that admits
everyone — no error, no warning, and a UI that looked correct the whole time.</p><p>An application with zero bindings isn't broken in any way authentik reports. It's doing
exactly what it should: nothing bound means nobody is excluded. <strong>Verify bindings from the
database or the application's own bindings tab — never from the wizard.</strong></p></div></div>
<p>Bind it from the application instead: <strong>Applications → LiteLLM → Policy / Group / User
Bindings → Create or bind…</strong>, then <strong>Bind a group</strong> under <em>Bind Existing…</em> → <code>gateway-admins</code>
→ <strong>Create</strong>.</p>
<p>Pick <strong>Bind a group</strong> specifically. The <em>Choose Policy Type</em> section below it creates
brand-new policies (Event Matcher, Expression, GeoIP); a group binding is already a rule and
needs none of them.</p>
<p><span class="zoomImage__wrap"><img alt="The Create Binding dialog with gateway-admins selected, Enabled on, Negate Result off and Failure Result set to Don&amp;#39;t Pass" src="https://development-wec.wiline.com/docs/assets/images/ak-bind-group-dialog-c20ad52e0f590a4df1c9a5e3aa411963.png" width="1897" height="945" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Then verify, because the table is what lied:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">binding count: 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">group Group gateway-admins | enabled True | negate False</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can restrict an application to a group instead of leaving it open to every account —
and, more importantly, verify the binding actually persisted (<code>app.bindings.all()</code>) rather
than trusting the wizard's own table.</p></div></div>
<p>That page states the mode plainly: <em>"The currently selected policy engine mode is ANY: Any
policy must match to grant access."</em> With one binding, ANY and ALL behave identically. With
two they don't — <strong>ANY means one passing rule is enough</strong>, so a stray <code>User Contractor</code>
binding sitting beside the group would grant precisely the access this post is trying to
deny.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--point-the-gateway-at-authentik">Step 4 — Point the gateway at authentik<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#step-4--point-the-gateway-at-authentik" class="hash-link" aria-label="Direct link to Step 4 — Point the gateway at authentik" title="Direct link to Step 4 — Point the gateway at authentik" translate="no">​</a></h2>
<p>LiteLLM reads generic OIDC settings from <code>GENERIC_*</code>. The endpoints come from the discovery
document:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> ~/llm-gateway/.env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">GENERIC_CLIENT_ID=&lt;client id from Step 2&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">GENERIC_CLIENT_SECRET=&lt;client secret from Step 2&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">GENERIC_AUTHORIZATION_ENDPOINT=http://10.80.4.212:9100/application/o/authorize/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">GENERIC_TOKEN_ENDPOINT=http://10.80.4.212:9100/application/o/token/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">GENERIC_USERINFO_ENDPOINT=http://10.80.4.212:9100/application/o/userinfo/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">PROXY_BASE_URL=http://localhost:4000</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p><code>PROXY_BASE_URL</code> is the one that bites. LiteLLM builds its redirect URI from it, so it has to
equal both what the browser types and what authentik has registered. Ours disagreed on the
first attempt and produced a <strong>Redirect URI Error</strong> — the <code>redirect_uri</code> in the address bar
is what LiteLLM sent, the Strict value on the provider is what authentik expected, and a
port, a hostname or a trailing slash between them is enough to fail.</p>
<p>The gateway's compose file uses <code>env_file: .env</code>, so unlike Langfuse in Part 3 there's no
override file to write:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> litellm </span><span class="token function" style="color:#d73a49">env</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> GENERIC_</span><br></div></code></pre></div></div>
<p>Five. Before the edit it was zero — the same class of check Part 3 used to catch variables
that never reached the process.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--both-sides-of-the-binding">Step 5 — Both sides of the binding<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#step-5--both-sides-of-the-binding" class="hash-link" aria-label="Direct link to Step 5 — Both sides of the binding" title="Direct link to Step 5 — Both sides of the binding" translate="no">​</a></h2>
<p>Open <code>http://localhost:4000/ui</code> through the tunnel. The login page carries a finding this
post didn't go looking for.</p>
<p><strong>Login with SSO</strong> appears, as intended. Above it sits a username and password form, and
LiteLLM's own panel explains it: <em>"By default, Username is <code>admin</code> and Password is your set
LiteLLM Proxy <code>MASTER_KEY</code>."</em></p>
<p><span class="zoomImage__wrap"><img alt="The LiteLLM login page showing a username and password form above the Login with SSO button, with a panel naming the master key as the default password" src="https://development-wec.wiline.com/docs/assets/images/litellm-login-sso-a0616071a645fb27014bfa0b35203eaa.png" width="1903" height="997" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The master key is a complete bypass. Anyone holding it reaches the admin UI without touching
authentik, without a group, without a binding. Everything configured in Steps 1–3 sits beside
a door that ignores all of it — and that door's key is also the gateway's root credential,
able to mint API keys and reach every model.</p>
<p><code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> sends <code>/ui</code> straight to authentik instead of showing the
form. It stops <em>advertising</em> the password path; it doesn't remove it. Treat the master key as
a break-glass credential and don't mistake the redirect for a fix.</p>
<p>Sign in as <code>akadmin</code>, who is in <code>gateway-admins</code>. authentik shows the consent screen from
Part 3's explicit-consent flow, and you land in the dashboard at <code>/ui/?login=success</code>.</p>
<p><span class="zoomImage__wrap"><img alt="authentik&amp;#39;s login screen reading Log in to continue to LiteLLM" src="https://development-wec.wiline.com/docs/assets/images/ak-login-litellm-9e4c240eccffd4f7e739199f9b9c594a.png" width="1800" height="797" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><span class="zoomImage__wrap"><img alt="The consent screen for akadmin, listing Email address and General Profile Information" src="https://development-wec.wiline.com/docs/assets/images/ak-consent-akadmin-ea2ed59007a2b3e896f2d0437553763f.png" width="1800" height="774" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Now <code>contractor</code>.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>All incognito windows share one session</div><div class="admonitionContent_BuS1"><p>Opening "a new private window" while another is already open reuses the same cookies, so
authentik signs you back in as the previous user. The symptom is an instant login that looks
like your access control failed. Close <strong>every</strong> incognito window, or use a different
browser.</p></div></div>
<p><strong>Permission denied. Request has been denied.</strong></p>
<p><span class="zoomImage__wrap"><img alt="authentik refusing contractor with Permission denied and Request has been denied" src="https://development-wec.wiline.com/docs/assets/images/ak-contractor-denied-14fb1a97964d42b993f3e49504ecb9dd.png" width="1761" height="694" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Note where that happened: authentik refused <em>before</em> the consent screen. The policy check
runs ahead of consent, so a blocked user never sees the permissions page — which is also how
we caught the missing bindings earlier. When <code>contractor</code> reached a consent screen, that was
the tell.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-part-that-is-not-closed">The part that is not closed<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#the-part-that-is-not-closed" class="hash-link" aria-label="Direct link to The part that is not closed" title="Direct link to The part that is not closed" translate="no">​</a></h2>
<p>From a shell on the box — no authentik account, no session, no group, nothing but a key:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer sk-..."</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'{model, answer: .choices[0].message.content}'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"model"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"qwen-mid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"answer"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"\n\nOK"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The curl call answering normally from a shell with no authentik session" src="https://development-wec.wiline.com/docs/assets/images/litellm-api-still-open-f3c81e08b6e2981f5cd844d8f236a4ec.png" width="532" height="186" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Nothing is broken. <strong>SSO never applied to that endpoint.</strong> Steps 1–5 protected <code>/ui</code> — the
control plane, where humans create keys and read spend. The data plane at <code>/v1/*</code>
authenticates machine callers with API keys, and it has to: an agent running at 3am can't
complete a browser login with a consent screen.</p>
<p>That key was itself created from a shell, with the master key, while no SSO session existed
anywhere:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/key/generate </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"key_alias":"sso-demo","models":[]}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.key'</span><br></div></code></pre></div></div>
<p>The consequences, stated plainly, because "we put SSO on the gateway" gets said in meetings
as though it settled the question:</p>
<ul>
<li class=""><strong>Removing someone from <code>gateway-admins</code> does not revoke their keys.</strong> They lose the UI and
keep every key they made. Offboarding is two actions; only one is the one people remember.</li>
<li class=""><strong>A leaked key is unaffected by identity entirely.</strong> No session to expire, no group to
remove it from.</li>
<li class=""><strong>Keys outlive people</strong>, unless you gave them an owner and an expiry when you made them.</li>
</ul>
<p>The gateway's own key model — scoped keys, budgets, expiry, per-key models — is what covers
that ground, and it's a different mechanism from the one configured here. Complementary, not
substitutable.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-more-layer-getting-in-is-not-being-able-to-do-anything">One more layer: getting in is not being able to do anything<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#one-more-layer-getting-in-is-not-being-able-to-do-anything" class="hash-link" aria-label="Direct link to One more layer: getting in is not being able to do anything" title="Direct link to One more layer: getting in is not being able to do anything" translate="no">​</a></h2>
<p>Signed in as <code>akadmin</code> through SSO, the Virtual Keys page had no <strong>Create Key</strong> button.</p>
<p>LiteLLM assigns SSO users a default role that isn't admin. Passing authentik's binding got us
<em>into</em> the UI; what we could <em>do</em> there is LiteLLM's own role model, which we never
configured. Authentication, then authorization, then application-level roles — three distinct
layers, and clearing one says nothing about the next.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="application-matching-query-does-not-exist"><code>Application matching query does not exist</code><a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#application-matching-query-does-not-exist" class="hash-link" aria-label="Direct link to application-matching-query-does-not-exist" title="Direct link to application-matching-query-does-not-exist" translate="no">​</a></h3>
<p>The <code>ak shell</code> lookup fails even though the application is visibly there in the UI. The slug
isn't what you assumed — authentik derives it from the name, and a CamelCase name like
<code>LiteLLM</code> becomes <code>lite-llm</code>. Ask rather than guess:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for a in Application.objects.all():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(repr(a.name), '-&gt;', repr(a.slug))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="redirect-uri-error">Redirect URI Error<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#redirect-uri-error" class="hash-link" aria-label="Direct link to Redirect URI Error" title="Direct link to Redirect URI Error" translate="no">​</a></h3>
<p>authentik refuses before any login form. The <code>redirect_uri</code> in the address bar is what
LiteLLM sent; the Strict value on the provider is what authentik expects. Ours disagreed
because <code>PROXY_BASE_URL</code> said one host and the registered URI said another. Print what's
registered and compare character by character:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">p = Application.objects.get(slug='lite-llm').get_provider()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for r in p.redirect_uris:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(repr(r.matching_mode), repr(r.url))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<p>If you edit it and the error persists, <strong>Applications → Clear cache</strong> — authentik caches
provider config.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="err_connection_refused-on-localhost4000"><code>ERR_CONNECTION_REFUSED</code> on <code>localhost:4000</code><a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#err_connection_refused-on-localhost4000" class="hash-link" aria-label="Direct link to err_connection_refused-on-localhost4000" title="Direct link to err_connection_refused-on-localhost4000" translate="no">​</a></h3>
<p>The tunnel isn't forwarding, even though ssh connected. If something already held the local
port, ssh printed <code>bind: Address already in use</code> and carried on with a working shell and a
dead forward. Check the port, then restart the tunnel:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">lsof</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-nP</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-iTCP:4000</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sTCP:LISTEN</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-user-i-expected-to-be-refused-got-in">The user I expected to be refused got in<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#the-user-i-expected-to-be-refused-got-in" class="hash-link" aria-label="Direct link to The user I expected to be refused got in" title="Direct link to The user I expected to be refused got in" translate="no">​</a></h3>
<p>Two causes, both real here. Either a <strong>stray user binding</strong> grants them directly — policy
engine mode ANY means one passing rule is enough — or <strong>the bindings were never saved</strong>. Ask
the database, not the UI:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">app = Application.objects.get(slug='lite-llm')</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">bs = list(app.bindings.all())</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('binding count:', len(bs))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for b in bs:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print('group', b.group, '| user', b.user, '| enabled', b.enabled, '| negate', b.negate)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<p>A user who reaches the <strong>consent screen</strong> has already passed the policy check — that's the
tell. A blocked user never gets that far.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-refused-user-logs-straight-in-without-a-prompt">The refused user logs straight in without a prompt<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#the-refused-user-logs-straight-in-without-a-prompt" class="hash-link" aria-label="Direct link to The refused user logs straight in without a prompt" title="Direct link to The refused user logs straight in without a prompt" translate="no">​</a></h3>
<p>Browser session, not policy. All Chrome incognito windows share one session, so opening
"a new private window" while another is open keeps the previous user signed in. Close every
incognito window, or use a different browser.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="no-create-key-button-in-the-admin-ui">No <strong>Create Key</strong> button in the admin UI<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#no-create-key-button-in-the-admin-ui" class="hash-link" aria-label="Direct link to no-create-key-button-in-the-admin-ui" title="Direct link to no-create-key-button-in-the-admin-ui" translate="no">​</a></h3>
<p>You're signed in through SSO, and LiteLLM gives SSO users a default role that isn't admin.
Create keys from the shell with the master key, or configure LiteLLM's own roles — a
separate mechanism from anything authentik does.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can tell a control plane from a data plane on your own stack: put SSO and group
membership in front of an admin UI, then prove the API behind it still answers on a key
alone — and name what that means for offboarding, leaked keys and keys that outlive people.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: a group that means something, a binding that enforces it and that we verified in the
database rather than trusting a table, and an admin UI where access follows membership.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>The API is untouched</strong>, by design, as demonstrated.</li>
<li class=""><strong>The master key still logs in</strong>, bypassing all of it.</li>
<li class=""><strong>SSO users have no LiteLLM role</strong> beyond the default.</li>
<li class=""><strong>Still plain HTTP.</strong> Every token and secret here crosses the network in the clear.</li>
<li class=""><strong>No MFA.</strong></li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Membership, not just an account</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>TLS and real exposure. The gateway is on <code>127.0.0.1:4000</code> and reachable in this post only
through an SSH tunnel; authentik is plain HTTP. Making either properly reachable means a
reverse proxy, a hostname and a certificate — and every redirect URI, <code>PROXY_BASE_URL</code> and
issuer changes the day that happens.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.goauthentik.io/docs/users-sources/groups/" target="_blank" rel="noopener noreferrer" class="">authentik docs — groups</a></li>
<li class=""><a href="https://docs.goauthentik.io/docs/customize/policies/" target="_blank" rel="noopener noreferrer" class="">authentik docs — bindings and the policy engine</a></li>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" class="">LiteLLM docs — SSO for the admin UI</a></li>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/virtual_keys" target="_blank" rel="noopener noreferrer" class="">LiteLLM docs — virtual keys</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>sso</category>
            <category>oidc</category>
            <category>authentik</category>
            <category>identity</category>
            <category>rbac</category>
            <category>litellm</category>
            <category>gateway</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Book appointments and refund invoices from a LangGraph agent over MCP]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/</guid>
            <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The scheduler and billing agents from Parts 1 and 2 never actually scheduled or billed anything. This gives them a calendar, a customer list and a refund that no model can issue alone — over MCP, on the stateless spec, with the model on WEC Inference. Includes three things nobody documents: your server still defaults to sessions, ctx.elicit is the old API and its era error goes to the model, not to you, and cache=True does nothing unless the server advertises a TTL.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__mcp" src="data:image/png;base64,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" alt="Model Context Protocol"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="487" height="249" fill="none" viewBox="0 0 487 249" class="tutorialHero__brand"><path fill="#1C3C3C" fill-rule="evenodd" d="M124.273.412h239.031c68.111 0 123.52 55.603 123.52 123.948s-55.409 123.948-123.52 123.948H124.273c-68.11 0-123.52-55.603-123.52-123.948S56.164.412 124.274.412M234.018 192.55c3.001 3.156 7.443 3 11.378 2.182l.039.02c1.827-1.486-.77-3.368-3.249-5.165-1.487-1.077-2.932-2.125-3.356-3.038 1.373-1.674-2.687-5.474-5.847-8.433-1.327-1.242-2.495-2.335-3.037-3.061-2.248-2.448-3.152-5.537-4.062-8.644-.603-2.061-1.208-4.131-2.211-6.027-6.176-14.339-13.248-28.561-23.165-40.699-6.373-8.067-13.646-15.291-20.922-22.518-4.691-4.66-9.382-9.319-13.835-14.206-4.581-4.73-7.338-10.556-10.1-16.392-2.312-4.886-4.627-9.779-8.018-14.04-10.268-15.196-42.686-19.346-47.44 2.124.019.662-.195 1.09-.78 1.52-2.63 1.928-4.968 4.11-6.935 6.76-4.812 6.721-5.553 18.118.448 24.158l.025-.388c.2-3.05.388-5.9 2.8-8.087 4.637 3.994 11.67 5.416 17.047 2.435 6.483 9.3 8.548 20.543 10.621 31.823 1.726 9.398 3.457 18.821 7.751 27.171l.266.443c2.524 4.202 5.089 8.472 8.326 12.142 1.176 1.823 3.59 3.791 6 5.755 3.18 2.591 6.353 5.177 6.663 7.416.015.974.01 1.961.006 2.954-.025 5.879-.051 11.967 3.716 16.801 2.084 4.228-3.02 8.475-7.131 7.949-2.254.312-4.716-.282-7.161-.871-3.346-.807-6.659-1.607-9.36-.064-.758.82-1.846.849-2.94.877-1.297.035-2.601.069-3.373 1.422-.158.402-.528.856-.913 1.328-.846 1.036-1.762 2.159-.665 3.016q.149-.111.294-.224c1.663-1.269 3.248-2.479 5.493-1.724-.299 1.658.772 2.103 1.843 2.547q.281.114.553.239c-.011.385-.087.774-.163 1.159-.18.921-.356 1.824.358 2.62.339-.344.639-.732.939-1.12.735-.95 1.474-1.904 2.802-2.25 2.92 3.9 5.862 2.28 9.554.248 4.164-2.293 9.281-5.111 16.396-1.125-2.727-.136-5.163.195-6.994 2.455-.448.507-.838 1.091-.039 1.754 4.21-2.728 5.961-1.748 7.61-.825 1.19.666 2.326 1.302 4.294.493.465-.242.93-.493 1.396-.745 3.161-1.705 6.366-3.434 10.118-2.839-2.803.808-3.8 2.584-4.888 4.524-.539.959-1.099 1.957-1.911 2.898-.429.429-.624.936-.137 1.656 5.869-.488 8.087-1.976 11.083-3.986 1.429-.959 3.036-2.038 5.302-3.183 2.505-1.542 5.009-.556 7.436.4 2.633 1.037 5.175 2.037 7.527-.264.743-.7 1.674-.708 2.602-.717a10 10 0 0 0 1.002-.043c-.732-3.92-4.861-3.874-9.052-3.827-4.847.054-9.778.109-9.632-5.972 4.504-3.076 4.546-8.415 4.585-13.461.01-1.218.019-2.419.091-3.567 3.313 1.847 6.817 3.291 10.299 4.725 3.276 1.35 6.533 2.692 9.593 4.354 3.195 5.143 8.182 11.962 14.826 11.514.175-.526.331-.974.526-1.5.383.067.787.169 1.199.273 1.743.442 3.611.915 4.509-1.15m130.213-58.45a20.54 20.54 0 0 0 14.504 5.994c5.44 0 10.658-2.156 14.505-5.994a20.44 20.44 0 0 0 6.007-14.469 20.44 20.44 0 0 0-6.007-14.469 20.54 20.54 0 0 0-21.882-4.624l-11.757-17.162-8.194 5.614 11.818 17.25a20.43 20.43 0 0 0-5.002 13.391 20.43 20.43 0 0 0 6.008 14.469m-36.808-55.576a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.784 20.47 20.47 0 0 0-7.873-10.09 20.557 20.557 0 0 0-18.35-2.282 20.5 20.5 0 0 0-7.964 5.175 20.44 20.44 0 0 0-4.77 8.201 20.429 20.429 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m0 118.824a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.783 20.47 20.47 0 0 0-7.873-10.092 20.55 20.55 0 0 0-26.314 2.894 20.45 20.45 0 0 0-4.77 8.201 20.43 20.43 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m18.857-72.629v-10.174h-31.394a20.3 20.3 0 0 0-4.398-8.342L322.3 88.704l-8.59-5.698-11.812 17.499a20.5 20.5 0 0 0-6.749-1.221 20.5 20.5 0 0 0-14.462 5.959 20.3 20.3 0 0 0-5.99 14.388 20.3 20.3 0 0 0 5.99 14.387 20.5 20.5 0 0 0 14.462 5.96 20.5 20.5 0 0 0 6.749-1.221l11.812 17.498 8.487-5.697-11.709-17.498a20.3 20.3 0 0 0 4.398-8.342z" clip-rule="evenodd"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 4 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->4</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->4<!-- --> earned</span></div><div class="skillTracker__series">Agent orchestration with LangGraph</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">State that survives a restart</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Hand work between agents</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">3</span><span class="skillTracker__skill" data-state="current">Governed tools an agent can call</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Engineer the context, not the prompt</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/">Part 1</a> gave an agent state that survives a
restart, and a pause where a human approves before it continues. <a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/">Part 2</a>
put a supervisor in front of a <strong>scheduler</strong> and a <strong>billing</strong> agent and routed work between
them.</p>
<p>Neither of those agents scheduled or billed anything. They discussed it. Every tool they had
was a function you wrote into the same process, and the interesting ones — look up a customer,
take a slot, move money — didn't exist.</p>
<p>This part builds the toolbox those two roles need, over <strong>MCP</strong>: a calendar, a customer list,
invoices, and a refund. To keep the moving parts down we point <strong>one</strong> agent at all five tools
rather than rebuilding Part 2's supervisor — the agent discovers them at runtime instead of
being wired to them, chains them to answer a question, and writes rows you can go and check in
the database afterwards. Then the refund stops it dead and makes a human type the amount.</p>
<p>Splitting these tools back across the scheduler and the billing agent — so the scheduler
<em>cannot</em> see <code>issue_refund</code> at all — is the natural next step, and the last section says how.</p>
<p>In July the MCP specification <a class="" href="https://development-wec.wiline.com/docs/news/mcp-2026-07-28-spec/">dropped sessions entirely</a>. On the
day we ran this, LangChain shipped support for that revision in the main package. So this is a
first run against a five-week-old protocol and a same-day client — which is why three things
in here aren't in anyone's documentation yet.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, Python 3.10. <code>langchain</code>
1.4.0, <code>langchain-openai</code> 1.6.0, <code>langgraph</code> 1.2.11, <code>mcp</code> 2.1.1, <code>fastmcp</code> 4.0.2 — all
installed on the day of writing. Models served through the LiteLLM gateway from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 of the gateway series</a>, which forwards to
WEC Inference. <code>langchain.mcp</code> is in beta and warns on every import; the API may move.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-mcp-actually-is">What MCP actually is<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#what-mcp-actually-is" class="hash-link" aria-label="Direct link to What MCP actually is" title="Direct link to What MCP actually is" translate="no">​</a></h2>
<p>A server publishes tools. A client asks it what tools exist. The model picks one and the
client calls it. That's the whole protocol — the value is that "asks what tools exist" is
standardised, so any client can use any server without glue written for the pair.</p>
<p>Three words you need:</p>
<p><strong>Tool.</strong> A function the server exposes, with a description and a JSON schema for its
arguments. The description is what the model reads to decide whether to call it; the schema is
what it must fill in. Nothing else about your code reaches the model.</p>
<p><strong>Discovery.</strong> The client asks <code>tools/list</code> and gets that catalog back. Your agent learns what
it can do at runtime instead of at the time you wrote it.</p>
<p><strong>Elicitation.</strong> A tool that can't finish without asking a human something — confirming a
delete, supplying a parameter the model left out. Under the new spec this is an ordinary
request the client retries with an answer attached.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-round-trip-that-replaced-the-session">The round trip that replaced the session<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#the-round-trip-that-replaced-the-session" class="hash-link" aria-label="Direct link to The round trip that replaced the session" title="Direct link to The round trip that replaced the session" translate="no">​</a></h2>
<p>Under the old spec a tool that needed input held the connection open and asked down a
back-channel. Under <code>2026-07-28</code> it returns, and the client calls it <strong>again</strong> with the answer
attached. Two complete requests instead of one long-lived one:</p>
<!-- -->
<p>The tool runs <strong>twice</strong>. That's why its body starts with <code>if ctx.input_responses is None</code> — it
has to be written to be re-entered, not resumed. Nothing is held open in between, so the answer
can arrive after a redeploy, or at a different replica behind a load balancer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">The virtualenv and WEC Inference access from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/">Part 1</a></li>
<li class="">A <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">LiteLLM gateway</a> with one virtual key, or any
OpenAI-compatible endpoint you can point a model at</li>
<li class="">Python 3.10+</li>
<li class="">Two terminal sessions on the box — the server blocks one of them</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--install">Step 1 — Install<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-1--install" class="hash-link" aria-label="Direct link to Step 1 — Install" title="Direct link to Step 1 — Install" translate="no">​</a></h2>
<p>MCP support now lives in the main <code>langchain</code> package. If you have <code>langchain-mcp-adapters</code>
installed from before, it is the thing this replaces.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> venv .venv </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/pip </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-q</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"langchain[mcp]"</span><span class="token plain"> langchain-openai httpx </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/pip list </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-iE</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'langchain|langgraph|^mcp |fastmcp'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="pip list showing langchain 1.4.0, langgraph 1.2.11, mcp 2.1.1 and fastmcp 4.0.2" src="https://development-wec.wiline.com/docs/assets/images/mcp-versions-5b3443d87f1bcddd968257037aceb26f.png" width="749" height="395" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><code>langchain[mcp]</code> needs <strong>1.4.0 or newer</strong> — below that, <code>MCPAdapter</code> doesn't exist.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>mcp[cli]</code> is gone</div><div class="admonitionContent_BuS1"><p>Older guides tell you to <code>pip install "mcp[cli]"</code>. On <code>mcp</code> 2.x that extra no longer exists,
and pip fails with a wall of <code>does not provide the extra 'cli'</code> across sixty versions before
giving up with <code>ResolutionImpossible</code>. It reads like a dependency conflict; it's a removed
extra.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--something-for-the-tools-to-act-on">Step 2 — Something for the tools to act on<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-2--something-for-the-tools-to-act-on" class="hash-link" aria-label="Direct link to Step 2 — Something for the tools to act on" title="Direct link to Step 2 — Something for the tools to act on" translate="no">​</a></h2>
<p>The agents need data that exists. SQLite, seeded once — three customers, six slots (one
already taken), four invoices.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/seed.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> sqlite3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">db </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">connect</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"office.db"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">db</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">executescript</span><span class="token punctuation" style="color:#393A34">(</span><span class="token triple-quoted-string string" style="color:#e3116c">"""</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">DROP TABLE IF EXISTS customers; DROP TABLE IF EXISTS slots; DROP TABLE IF EXISTS invoices;</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT, email TEXT);</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">CREATE TABLE slots(id INTEGER PRIMARY KEY, day TEXT, time TEXT, customer_id INTEGER);</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">CREATE TABLE invoices(id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL, status TEXT);</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">INSERT INTO customers(name,email) VALUES</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> ('Maria Alvarez','maria@example.com'),</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> ('Tomas Reis','tomas@example.com'),</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> ('Priya Nair','priya@example.com');</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">INSERT INTO slots(day,time,customer_id) VALUES</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> ('2026-09-04','09:00',NULL),('2026-09-04','11:00',NULL),('2026-09-04','14:00',2),</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> ('2026-09-05','09:00',NULL),('2026-09-05','11:00',NULL),('2026-09-05','16:00',NULL);</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">INSERT INTO invoices(customer_id,amount,status) VALUES</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c"> (1,240.00,'paid'),(1,80.00,'open'),(2,150.00,'paid'),(3,410.00,'open');</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">db</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">commit</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Two details are load-bearing. <strong>Maria has both a paid and an open invoice</strong>, so "refund Maria's
invoice" is ambiguous and the agent has to resolve it. <strong>Tomas already holds Friday at 14:00</strong>,
so availability is a real question rather than "everything".</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-toolbox">Step 3 — The toolbox<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-3--the-toolbox" class="hash-link" aria-label="Direct link to Step 3 — The toolbox" title="Direct link to Step 3 — The toolbox" translate="no">​</a></h2>
<p>Five tools: three reads, one write, one that cannot finish without a human.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/office_tools.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> sqlite3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> FastMCP</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Context</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> mcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> InputRequiredResult</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ElicitRequest</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ElicitRequestFormParams</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DB </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirname</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">abspath</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">__file__</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"office.db"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">mcp </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> FastMCP</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"office-tools"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sql</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> args</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    con </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">connect</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">row_factory </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Row</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">try</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        rows </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">execute</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sql</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> args</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">fetchall</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">commit</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> r </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> rows</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">finally</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">w</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sql</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> args</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    con </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">connect</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">try</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        cur </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">execute</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sql</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> args</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">commit</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> cur</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">rowcount</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">finally</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        con</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">find_customer</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">query</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""Find customers whose name or email matches the query."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    like </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"%</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">query</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">%"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"SELECT id, name, email FROM customers WHERE name LIKE ? OR email LIKE ?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">like</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> like</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">list_availability</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">day</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""List free appointment slots on a given day, formatted YYYY-MM-DD."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"SELECT id, day, time FROM slots WHERE day = ? AND customer_id IS NULL ORDER BY time"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">day</span><span class="token punctuation" style="color:#393A34">,</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">book_slot</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">slot_id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> customer_id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""Book a free slot for a customer. Fails if the slot is already taken."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    n </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> w</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"UPDATE slots SET customer_id = ? WHERE id = ? AND customer_id IS NULL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">customer_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> slot_id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> n </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Slot </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">slot_id</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> is already taken or does not exist."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    row </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"SELECT day, time FROM slots WHERE id = ?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">slot_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Booked slot </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">slot_id</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> (</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">row</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'day'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">row</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'time'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">) for customer </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">customer_id</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">open_invoices</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">customer_id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""List a customer's invoices with their amounts and status."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"SELECT id, amount, status FROM invoices WHERE customer_id = ?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">customer_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@mcp</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">issue_refund</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">invoice_id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ctx</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Context</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> InputRequiredResult</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""Refund an invoice. A human must approve the amount and give a reason."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    inv </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"SELECT id, customer_id, amount, status FROM invoices WHERE id = ?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">invoice_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> inv</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"No invoice </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">invoice_id</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    inv </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> inv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    responses </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ctx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">input_responses</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> responses </span><span class="token keyword" style="color:#00009f">is</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        params </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ElicitRequestFormParams</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            message</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string-interpolation string" style="color:#e3116c">f"Refund invoice </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'id'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> for customer </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'customer_id'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">? "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string-interpolation string" style="color:#e3116c">f"It is </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'status'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">, amount </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'amount'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">. "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"Confirm the amount to refund and give a reason."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            requested_schema</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"object"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"properties"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    </span><span class="token string" style="color:#e3116c">"amount"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"number"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"description"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Amount to refund"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    </span><span class="token string" style="color:#e3116c">"reason"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"string"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"description"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Why this refund is being issued"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"required"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"amount"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"reason"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> InputRequiredResult</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            result_type</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"input_required"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            input_requests</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"approve_refund"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ElicitRequest</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">method</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"elicitation/create"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> params</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">params</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    answer </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> responses</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"approve_refund"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> answer</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">action </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"accept"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Refund declined - invoice </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'id'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> unchanged."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    w</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"UPDATE invoices SET status = 'refunded' WHERE id = ?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">inv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"id"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Refunded </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">answer</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">content</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'amount'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> on invoice </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">inv</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'id'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">. Reason: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">answer</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">content</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'reason'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> __name__ </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__main__"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    mcp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">transport</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"http"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> host</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"127.0.0.1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> port</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">8770</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> stateless_http</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The docstrings are not comments. They become the tool descriptions the model reads to choose,
so write them as instructions to someone who can see nothing but that one line.</p>
<p><code>book_slot</code> refuses a taken slot in SQL rather than checking first — <code>WHERE customer_id IS NULL</code>
means two agents racing for the same slot can't both win.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python office_tools.py</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--the-session-that-shouldnt-exist">Step 4 — The session that shouldn't exist<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-4--the-session-that-shouldnt-exist" class="hash-link" aria-label="Direct link to Step 4 — The session that shouldn't exist" title="Direct link to Step 4 — The session that shouldn't exist" translate="no">​</a></h2>
<p>Leave it running. In a second session, ask what tools it has:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:8770/mcp </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Accept: application/json, text/event-stream'</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'</span><br></div></code></pre></div></div>
<p>The first time we ran this, without <code>stateless_http=True</code>:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"jsonrpc"</span><span class="token operator" style="color:#393A34">:</span><span class="token string" style="color:#e3116c">"2.0"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"id"</span><span class="token operator" style="color:#393A34">:</span><span class="token null keyword" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"error"</span><span class="token operator" style="color:#393A34">:</span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"code"</span><span class="token operator" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">-32600</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"message"</span><span class="token operator" style="color:#393A34">:</span><span class="token string" style="color:#e3116c">"Bad Request: Missing session ID"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The MCP endpoint refusing a request with Bad Request: Missing session ID" src="https://development-wec.wiline.com/docs/assets/images/mcp-session-error-7897aebee33998b591bd27b4606a6e6b.png" width="666" height="75" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>The spec is stateless. Your server isn't, by default.</div><div class="admonitionContent_BuS1"><p>The 2026-07-28 revision removed sessions from the protocol — no handshake, no session ID,
nothing pinning a client to one instance. Every write-up of the change describes the client
side accurately: there is nothing left to pin.</p><p><strong>FastMCP 4.0.2 still defaults to the stateful transport.</strong> You opt in with
<code>stateless_http=True</code>, and until you do, the first request against a brand-new server fails
citing a concept the spec deleted five weeks ago.</p><p><code>run_http_async</code> accepts both <code>stateless_http</code> and <code>stateless</code>; the docstring says the second
is "Alias for stateless_http for CLI consistency." One switch, two names, both defaulting to
<code>None</code>.</p></div></div>
<p>The startup line is where you check it:</p>
<p><span class="zoomImage__wrap"><img alt="FastMCP starting without the stateless marker" src="https://development-wec.wiline.com/docs/assets/images/mcp-server-start-9b984393ff73ca52793743fe31bbe9da.png" width="756" height="488" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><span class="zoomImage__wrap"><img alt="FastMCP starting with transport http (stateless)" src="https://development-wec.wiline.com/docs/assets/images/mcp-server-stateless-8cd7f1d5201b5dc974f4726c52b23f9f.png" width="776" height="482" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>With the flag, the same request returns the catalog — no handshake, no session:</p>
<p><span class="zoomImage__wrap"><img alt="The five office tools listed with their descriptions" src="https://development-wec.wiline.com/docs/assets/images/mcp-tools-list-c1f510e50217be4d48c633e07dafa45d.png" width="744" height="180" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><code>issue_refund</code>'s schema contains only <code>invoice_id</code> — the <code>ctx: Context</code> parameter is absent,
because FastMCP injects it and the model can't set it. That's what makes a mandatory
confirmation mandatory.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--an-agent-that-discovers-its-tools">Step 5 — An agent that discovers its tools<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-5--an-agent-that-discovers-its-tools" class="hash-link" aria-label="Direct link to Step 5 — An agent that discovers its tools" title="Direct link to Step 5 — An agent that discovers its tools" translate="no">​</a></h2>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/agent.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> asyncio</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> sys</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">agents </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> create_agent</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> MCPAdapter</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain_openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> ChatOpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">model </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:4000/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"GATEWAY_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"qwen-mid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> MCPAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:8770/mcp"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        tools </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">list_tools</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"discovered:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">t</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">name </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> t </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> tools</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        agent </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> create_agent</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">model</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> tools</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> agent</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ainvoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"\n--- answer ---"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Every published MCP example points the model at Anthropic, OpenAI or Gemini. This one points at
your own <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">LiteLLM gateway</a>, which forwards to WEC
Inference — the tools are local, the model is yours, nothing leaves the box.</p>
<p>Keep the key in a file rather than on the command line, where it would end up in your shell
history and every screenshot:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'GATEWAY_KEY=%s\n'</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">chmod</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">600</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python agent.py </span><span class="token string" style="color:#e3116c">"Who is Maria and what invoices does she have?"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The agent chaining find_customer into open_invoices and reporting both of Maria&amp;#39;s invoices" src="https://development-wec.wiline.com/docs/assets/images/mcp-agent-lookup-d1e719855fca0eb978d89518c2a03cac.png" width="983" height="209" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>That answer took <strong>two tool calls in sequence</strong>: <code>find_customer("Maria")</code> to get her id, then
<code>open_invoices(1)</code> using it. Nothing routed that — the model read five descriptions and worked
out the order.</p>
<p>Now one that writes:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python agent.py </span><span class="token string" style="color:#e3116c">"Book Maria into a free slot on 2026-09-04"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The agent finding Maria, listing Friday&amp;#39;s free slots, and booking 09:00" src="https://development-wec.wiline.com/docs/assets/images/mcp-agent-booking-cf43e46e8fbeb7003efd7d2ceba1ddc4.png" width="982" height="157" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Three calls this time. And because a booking is a real change, check it somewhere the agent
can't reach:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/mcp-tools </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> ./.venv/bin/python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import sqlite3</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for r in sqlite3.connect('office.db').execute('select s.id,s.day,s.time,coalesce(c.name,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">free</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">) from slots s left join customers c on c.id=s.customer_id order by s.day,s.time'): print(r)"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The slots table showing Maria booked into 2026-09-04 09:00" src="https://development-wec.wiline.com/docs/assets/images/mcp-verify-db-e3eda545965f9b86a42918a3fd4bc674.png" width="992" height="173" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can stand up an MCP server over a real datastore, point an agent at it, and have it chain
discovered tools into a change you can verify in the database — with the model on your own
gateway rather than a vendor's.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--the-tool-that-refuses-to-finish-alone">Step 6 — The tool that refuses to finish alone<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-6--the-tool-that-refuses-to-finish-alone" class="hash-link" aria-label="Direct link to Step 6 — The tool that refuses to finish alone" title="Direct link to Step 6 — The tool that refuses to finish alone" translate="no">​</a></h2>
<p>Part 1's approval pause was something <em>you</em> put in the graph. This one comes from the tool, and
the agent has no way to skip it.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/elicit.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> asyncio</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">agents </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> create_agent</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> MCPAdapter</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain_openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> ChatOpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">checkpoint</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">memory </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> InMemorySaver</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Command</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">model </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:4000/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                   api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"GATEWAY_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"qwen-mid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ask_human</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">req</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"\n&gt;&gt;&gt; </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">req</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'message'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    props </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">req</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"requested_schema"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"properties"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    content </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> spec </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> props</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">items</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        raw </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">input</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"    </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">name</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> (</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">spec</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">get</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'type'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">,</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'string'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">): "</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        content</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">name</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">float</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">raw</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> spec</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"number"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> raw</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"action"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"accept"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> content</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> MCPAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:8770/mcp"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        tools </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">list_tools</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        agent </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> create_agent</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">model</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> tools</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">InMemorySaver</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        cfg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"refund-1"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> agent</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ainvoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Refund Maria's open invoice."</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cfg</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"NO INTERRUPT:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        payload </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"__interrupt__"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">value</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"=== INTERRUPT ==="</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dumps</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">payload</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> indent</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        responses </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"key"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ask_human</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> r </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> payload</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"requests"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"\n=== RESUMING ==="</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        out </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> agent</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ainvoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">resume</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"responses"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> responses</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cfg</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">out</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>checkpointer</code> and <code>thread_id</code> are not optional — an interrupted run needs somewhere to
wait. Same requirement as Part 1's approval pause.</p>
<p><span class="zoomImage__wrap"><img alt="The interrupt payload, the typed amount and reason, and the resumed result" src="https://development-wec.wiline.com/docs/assets/images/mcp-elicit-full-b00707c0653a77cd26dcf4221b184fa5.png" width="1044" height="732" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Read the order of events. The model resolved <em>"Maria's open invoice"</em> to invoice 2 by itself —
she has two, and it picked the open one. Then it stopped. <strong>The amount and the reason were
typed by a human</strong>, and only then did the refund happen.</p>
<p>The payload is <code>{"type": "mcp_elicitation", "tool_name": "issue_refund", "requests": [...]}</code>.
<code>type</code> is the discriminator, so a graph with several kinds of interrupt can tell which came
from MCP. Each request carries a <code>key</code>, a <code>message</code> for a human, a <code>mode</code> of <code>form</code> or <code>url</code>,
and for a form the <code>requested_schema</code> the answer must satisfy — here two required fields, one
of them a number. You resume with one answer per key.</p>
<p>And verify, again outside the agent:</p>
<p><span class="zoomImage__wrap"><img alt="The invoices table showing invoice 2 as refunded" src="https://development-wec.wiline.com/docs/assets/images/mcp-verify-refund-3f9c62f71c37c5e0339cf4d1a4da57df.png" width="1043" height="356" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can make a tool refuse to finish without a human — returning a typed form the model cannot
fill in itself, pausing the run as a LangGraph interrupt, and resuming it with an answer a
person actually typed.</p></div></div>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span><code>ctx.elicit</code> is the old API, and its error lands in the model's context</div><div class="admonitionContent_BuS1"><p>Every FastMCP elicitation example you will find calls <code>await ctx.elicit(message, response_type)</code>
inside the tool. That is the <strong>handshake-era</strong> mechanism: it blocks mid-execution and speaks
over the session's back-channel — the back-channel the stateless transport doesn't have.</p><p>Our first attempt used it. The agent didn't pause — but not because nothing was raised. FastMCP
raised its era error exactly as documented, LangChain converted it into a <code>ToolMessage</code> with
<code>status="error"</code> (deliberately, "instead of ending the run"), and the model read that error and
narrated it as progress:</p><p><span class="zoomImage__wrap"><img alt="The agent run printing interrupt raised? False, issue_refund with status=error carrying the era error, the model replying that it has initiated the refund and a human must approve, and sqlite3 showing invoice 2 still open" src="https://development-wec.wiline.com/docs/assets/images/mcp-elicit-old-api-ec02d789aa6970b70a130f83067c235b.png" width="1038" height="437" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p><p>No interrupt, exit code 0, and the invoice still <code>open</code>. Nothing was refunded and nothing is
waiting for a human — but the user was told an approval is pending.</p><p>On the modern protocol a tool <strong>returns</strong> an <code>InputRequiredResult</code> describing what it needs, and
exits. The client answers and <strong>retries the whole tool call</strong> with the answer attached. Each
round is a complete request, which is why it survives load balancing and redeploys — and why
the tool body must be written to be re-entered rather than resumed. Ours does that with
<code>if ctx.input_responses is None</code>.</p><p>FastMCP's docs are explicit that <code>ctx.elicit</code> works only on connections <code>≤ 2025-11-25</code>, and that
a multi-era server should branch on <code>ctx.request_context.protocol_version</code>. They also promise the
mismatch "raises a clear era error rather than failing obscurely" — and it does. The error just
lands in the model's context rather than yours.</p></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The announcement's snippet doesn't run</div><div class="admonitionContent_BuS1"><p>LangChain's post reads the interrupt as <code>paused["__interrupt__"][0].value.requests[0]</code> —
attribute access. <code>MCPElicitationInterrupt</code> is a <code>TypedDict</code> (see <code>langchain/mcp/elicitation.py</code>
in 1.4.0), so at runtime it is a dict and <code>.requests</code> doesn't resolve. Use <code>value["requests"]</code> —
as the next line of their own snippet does, reading <code>question["key"]</code>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-7--caching-and-why-it-does-nothing-here">Step 7 — Caching, and why it does nothing here<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#step-7--caching-and-why-it-does-nothing-here" class="hash-link" aria-label="Direct link to Step 7 — Caching, and why it does nothing here" title="Direct link to Step 7 — Caching, and why it does nothing here" translate="no">​</a></h2>
<p>Every run starts by discovering tools, which is a round trip before the model sees anything.
The new spec makes that cacheable:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/mcp-tools/cached.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> asyncio</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> time</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastmcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mcp </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> MCPAdapter</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    client </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> Client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"http://127.0.0.1:8770/mcp"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cache</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> MCPAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> i </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            t </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">perf_counter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            tools </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">list_tools</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">cache_mode</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"use"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"discovery </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">i</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation builtin">len</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">tools</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> tools in </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">time</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">perf_counter</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation operator" style="color:#393A34">-</span><span class="token string-interpolation interpolation">t</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation operator" style="color:#393A34">*</span><span class="token string-interpolation interpolation number" style="color:#36acaa">1000</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.1f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> ms"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">discovery 1: 5 tools in 14.6 ms</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">discovery 2: 5 tools in 12.5 ms</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Two discoveries of five tools timed at 14.6 ms and 12.5 ms, showing no cache effect" src="https://development-wec.wiline.com/docs/assets/images/mcp-cache-bbadf88f6b26a069ec15e3b3db635c02.png" width="801" height="125" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>No effect — and that is correct behaviour, not a broken cache.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>cache=True</code> is necessary, not sufficient</div><div class="admonitionContent_BuS1"><p>The client cache "respects the <code>ttlMs</code> and <code>cacheScope</code> hints the server attaches to each
response" and works only against modern-era servers that advertise them. Our server advertises
none, so both calls go to the network.</p><p>Note also that <code>cache=True</code> goes on the <strong><code>fastmcp.Client</code></strong>, not on <code>MCPAdapter</code> — the
adapter takes exactly one argument. And the cache belongs to the client, so one client per
caller keeps catalogs from crossing between tenants.</p></div></div>
<p>Over loopback there was nothing to save anyway: 12–15 ms is the round trip. Caching is for remote
servers with real latency and a TTL to honour.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="resolutionimpossible-installing-mcpcli"><code>ResolutionImpossible</code> installing <code>mcp[cli]</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#resolutionimpossible-installing-mcpcli" class="hash-link" aria-label="Direct link to resolutionimpossible-installing-mcpcli" title="Direct link to resolutionimpossible-installing-mcpcli" translate="no">​</a></h3>
<p>The <code>cli</code> extra was removed in <code>mcp</code> 2.x. Install <code>"langchain[mcp]"</code> instead; it pulls a
compatible <code>mcp</code> and <code>fastmcp</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="bad-request-missing-session-id"><code>Bad Request: Missing session ID</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#bad-request-missing-session-id" class="hash-link" aria-label="Direct link to bad-request-missing-session-id" title="Direct link to bad-request-missing-session-id" translate="no">​</a></h3>
<p>Your server is running the stateful transport. Add <code>stateless_http=True</code> to <code>mcp.run(...)</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="keyerror-gateway_key"><code>KeyError: 'GATEWAY_KEY'</code><a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#keyerror-gateway_key" class="hash-link" aria-label="Direct link to keyerror-gateway_key" title="Direct link to keyerror-gateway_key" translate="no">​</a></h3>
<p><code>source .env</code> sets shell variables, not environment variables — the Python child never sees
them. Use <code>set -a &amp;&amp; source .env &amp;&amp; set +a</code>. And <code>export</code> does not cross SSH sessions, so a
second terminal needs its own <code>source</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="address-already-in-use-after-restarting-the-server"><code>address already in use</code> after restarting the server<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#address-already-in-use-after-restarting-the-server" class="hash-link" aria-label="Direct link to address-already-in-use-after-restarting-the-server" title="Direct link to address-already-in-use-after-restarting-the-server" translate="no">​</a></h3>
<p>The previous instance is still holding the port — a crashed restart leaves the old process
alive, so the fix you just made appears not to work. <code>pkill -f office_tools.py</code>, then check
with <code>ss -tlnp | grep 8770</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-agent-never-pauses-on-a-destructive-tool">The agent never pauses on a destructive tool<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#the-agent-never-pauses-on-a-destructive-tool" class="hash-link" aria-label="Direct link to The agent never pauses on a destructive tool" title="Direct link to The agent never pauses on a destructive tool" translate="no">​</a></h3>
<p>The tool is calling <code>ctx.elicit</code> rather than returning <code>InputRequiredResult</code>. On a stateless
connection there is no back-channel. The era error is raised, but it arrives as a failed
<code>ToolMessage</code> the model reads and talks past, so the run ends normally with nothing pending.
Check the tool messages, not just the final answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: an MCP server over a real datastore, an agent that discovers tools rather than being
wired to them and chains them without routing, two changes you verified in the database rather
than taking the model's word for, and a refund that cannot happen without a human supplying the
number.</p>
<p>Not done:</p>
<ul>
<li class=""><strong>No auth on the MCP server.</strong> It is bound to <code>127.0.0.1</code> and anyone on the box can call every
tool, including <code>issue_refund</code>. FastMCP supports bearer tokens and OAuth 2.1; we configured
neither.</li>
<li class=""><strong>The tools trust the caller completely.</strong> <code>book_slot</code> will book for any <code>customer_id</code> it is
given. There is no notion of <em>who</em> is asking — the same gap
<a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">Part 4 of the hardening series</a> found
between a control plane and a data plane.</li>
<li class=""><strong>One server.</strong> <code>ClientGroup</code> connects several at once and prefixes tool names by server; we
used a single target.</li>
<li class=""><strong>SQLite, single process.</strong> Fine for one agent; the row-level guard in <code>book_slot</code> is doing
more work than it looks under any real concurrency.</li>
<li class=""><strong><code>langchain.mcp</code> is beta</strong> and warns on every import.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Governed tools an agent can call</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p><strong>Scope the tools per agent.</strong> Right now one agent holds all five, so nothing stops the model
choosing <code>issue_refund</code> when it was asked to book a slot — the human pause is the only guard.
Part 2's supervisor already routes between a scheduler and a billing agent; hand each of them
only its own slice of <code>adapter.list_tools()</code> and the scheduler cannot issue a refund because it
has never been told the tool exists. That is a stronger guarantee than asking a model nicely.</p>
<p><strong>Then authentication</strong>, so a tool call carries an identity rather than trusting whoever reached
the port — the same question <a class="" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/">Part 4 of the hardening series</a>
asked about the gateway's own API, one layer up.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.langchain.com/oss/python/langchain/mcp" target="_blank" rel="noopener noreferrer" class="">MCP in LangChain</a> — quickstart, connections, auth, elicitation</li>
<li class=""><a href="https://docs.langchain.com/oss/python/migrate/langchain-mcp-adapters" target="_blank" rel="noopener noreferrer" class="">Migrating from <code>langchain-mcp-adapters</code></a></li>
<li class=""><a href="https://gofastmcp.com/clients/client" target="_blank" rel="noopener noreferrer" class="">FastMCP client</a> — transports, auth, response caching</li>
<li class=""><a href="https://modelcontextprotocol.io/specification/2026-07-28" target="_blank" rel="noopener noreferrer" class="">MCP <code>2026-07-28</code> specification</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/news/mcp-2026-07-28-spec/">Our write-up of the spec revision</a></li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>agents</category>
            <category>langgraph</category>
            <category>langchain</category>
            <category>mcp</category>
            <category>tools</category>
            <category>self-hosting</category>
            <category>wec</category>
            <category>wec-inference</category>
        </item>
        <item>
            <title><![CDATA[One login for everything: putting authentik in front of a self-hosted app]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/</guid>
            <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 2 fixed how containers run. This fixes who gets to log in. Deploy authentik as your own identity provider, connect Langfuse to it over OIDC, and understand the pieces — provider, application, redirect URI, scopes — instead of copying a config. Includes the two failures a real run produced: a compose file that silently swallows your settings, and OAuthAccountNotLinked.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__authentik" src="data:image/png;base64,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" alt="authentik"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">3</span><span class="skillTracker__skill" data-state="current">Identity in front of every port</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Part 1</a> closed the ports nobody meant to
open. <a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/">Part 2</a> stopped two containers
from running as root. Both were about the machine. Neither touched the question a
self-hosted AI stack answers worst: <strong>who is allowed to log in, and where is that
decided?</strong></p>
<p>Same box as before. The Langfuse we've been hardening since Part 1 — the one whose
Postgres, ClickHouse and Redis Part 1 found correctly bound to localhost, and whose
worker Part 2 dropped off root — was deployed back in
<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">Catch what your tests miss</a>. Two parts
have now secured the machine underneath it without once touching the application's own
front door. This is the first part that changes Langfuse itself.</p>
<p>Right now that decision is made in each app, separately. Langfuse has its own email-and-
password table. So does every other tool on the box. Each one is a place where an account
can outlive the person who owned it, where a password can be reused, and where "remove
this person's access" means remembering that the app exists at all.</p>
<p>This post moves that decision to one place. That place is
<a href="https://goauthentik.io/" target="_blank" rel="noopener noreferrer" class="">authentik</a> — an open-source identity provider you run
yourself, the self-hosted counterpart to Okta or Auth0. It holds the accounts, runs the
login screen, and vouches for who someone is to any app that asks. Apps stop storing
passwords and start asking authentik.</p>
<p>It's the same job Keycloak does, and Keycloak is the better-known name. authentik earns
the pick here on setup cost: a compose file and a wizard against Keycloak's realms,
clients and JVM tuning. For one box and one app, that difference is the whole decision.</p>
<p>We deploy it, connect Langfuse to it over OIDC, and end with a login that goes through
authentik and back.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15.
authentik <code>2026.8.1</code> from <code>ghcr.io/goauthentik/server</code>, Postgres 16. Langfuse v3
(<code>langfuse/langfuse:3</code>) — the deployment carried over from Parts 1 and 2, reachable at
<code>10.80.4.212:3001</code>. authentik published on host port <code>9100</code>.</p><p>Addresses in this post are a private LAN. Substitute yours. Everything here is plain
HTTP on a trusted network, which is fine for a first run and <strong>not</strong> fine for anything
reachable from outside — see <a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#whats-next" class="">What's next</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-vocabulary-before-the-clicking">The vocabulary, before the clicking<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#the-vocabulary-before-the-clicking" class="hash-link" aria-label="Direct link to The vocabulary, before the clicking" title="Direct link to The vocabulary, before the clicking" translate="no">​</a></h2>
<p>Every OIDC guide assumes you already know these five words. Getting them straight up
front is the difference between configuring this once and guessing at fields for an
hour.</p>
<p><strong>OP and RP.</strong> The <strong>OpenID Provider</strong> is the thing that knows who people are —
authentik. The <strong>Relying Party</strong> is the app that wants to be told — Langfuse. The RP
never sees a password. It receives a signed statement from the OP saying "this is
<code>admin@example.com</code>, I checked."</p>
<p><strong>Provider vs application.</strong> authentik splits what most tools merge. A <strong>provider</strong> is
the protocol endpoint — the OIDC machinery, the client ID and secret, the redirect URI.
An <strong>application</strong> is the thing users see and the thing access rules attach to. They
pair one-to-one: one application, one provider, and the wizard in Step 3 creates both
together.</p>
<p><strong>The redirect URI</strong> is where authentik is allowed to send the user back after a
successful login. It's matched exactly and it's the single most common source of
failure. Not a prefix, not a hostname — the full URL, character for character.</p>
<p><strong>Scopes vs grant types.</strong> A <strong>scope</strong> is what information the app asks for
(<code>openid email profile</code>). A <strong>grant type</strong> is the mechanism by which it asks
(<code>authorization_code</code>). Different questions; the UI puts them near each other and it's
easy to conflate them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-login-actually-does">What the login actually does<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#what-the-login-actually-does" class="hash-link" aria-label="Direct link to What the login actually does" title="Direct link to What the login actually does" translate="no">​</a></h2>
<!-- -->
<p>The part worth noticing: the code travels through the browser, the secret never does.
That exchange in the second-to-last step happens between the two containers directly,
which is why the client secret is a server-side setting and why Langfuse must be able to
reach authentik over the network — not just your browser.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A running Langfuse, ideally the one from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Part 1</a></li>
<li class="">Docker with the Compose plugin</li>
<li class="">A free host port for authentik (we use <code>9100</code>)</li>
<li class="">The LAN IP of the box, not <code>localhost</code> — two containers need to reach each other and
your browser needs to reach both</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--deploy-authentik">Step 1 — Deploy authentik<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-1--deploy-authentik" class="hash-link" aria-label="Direct link to Step 1 — Deploy authentik" title="Direct link to Step 1 — Deploy authentik" translate="no">​</a></h2>
<p>authentik ships a compose file and generates its own secrets. Create a directory and
pull both down:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-O</span><span class="token plain"> https://goauthentik.io/docker-compose.yml</span><br></div></code></pre></div></div>
<p>Now the environment file. authentik needs a Postgres password and a secret key, both
random, plus the ports it will publish:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PG_PASS=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-base64</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">36</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable operator" style="color:#393A34">|</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable function" style="color:#d73a49">tr</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable parameter variable" style="color:#36acaa">-d</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable string" style="color:#e3116c">'\n'</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"AUTHENTIK_SECRET_KEY=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-base64</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">60</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable operator" style="color:#393A34">|</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable function" style="color:#d73a49">tr</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable parameter variable" style="color:#36acaa">-d</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable string" style="color:#e3116c">'\n'</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"COMPOSE_PORT_HTTP=9100"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"COMPOSE_PORT_HTTPS=9543"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">chmod</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">600</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<p><code>AUTHENTIK_SECRET_KEY</code> signs sessions and tokens. If you lose it, every existing session
and every issued token becomes invalid — so it belongs in your password manager, not
only in this file.</p>
<p>Bring it up. First boot runs database migrations and takes a minute or two:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p>Three containers — <code>server</code>, <code>worker</code> and <code>postgresql</code> — all reporting <code>(healthy)</code>.
Older authentik compose files also shipped a separate <code>redis</code> service; <code>2026.8.1</code> no
longer does, so three is correct and nothing is missing. <code>server</code> is the one that has to
be healthy before you continue.</p>
<p><span class="zoomImage__wrap"><img alt="docker compose ps showing authentik&amp;#39;s server, worker and postgresql containers all reporting healthy" src="https://development-wec.wiline.com/docs/assets/images/authentik-compose-ps-2cdcd74dfeb2be5f3c8d9bb137b646db.png" width="1127" height="102" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--claim-the-admin-account">Step 2 — Claim the admin account<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-2--claim-the-admin-account" class="hash-link" aria-label="Direct link to Step 2 — Claim the admin account" title="Direct link to Step 2 — Claim the admin account" translate="no">​</a></h2>
<p>authentik's first boot leaves the admin account unclaimed. The setup URL exists exactly
once; visiting it is how you take ownership.</p>
<p>Open <code>http://10.80.4.212:9100/if/flow/initial-setup/</code>, and set the email and password
for the default admin (<code>akadmin</code>).</p>
<p><span class="zoomImage__wrap"><img alt="The authentik initial-setup screen, setting the email and password for the default akadmin account" src="https://development-wec.wiline.com/docs/assets/images/authentik-initial-setup-e170a5fefdbc3d6a5b645819857b6cae.png" width="1900" height="955" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Use an address you control. This account can do everything, so give it a real password
and store it somewhere durable.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>This URL is a one-time key</div><div class="admonitionContent_BuS1"><p>Until someone completes this form, anyone who can reach the port can become the
administrator of your identity provider. Do it immediately after the containers come up
— not tomorrow.</p></div></div>
<p>You land on the Application Dashboard — the user-facing view, not the admin one, and empty
because nothing is configured yet.</p>
<p><span class="zoomImage__wrap"><img alt="The authentik Application Dashboard reading No Applications available, with a Create a new application button" src="https://development-wec.wiline.com/docs/assets/images/authentik-app-dashboard-empty-fb4aaff0a94a00ac91abe85b9541079d.png" width="1902" height="954" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><code>Create a new application</code> here goes to the same place as the admin interface does; the
<strong>Admin interface</strong> button top-right is how you reach everything else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--run-the-application-wizard">Step 3 — Run the application wizard<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-3--run-the-application-wizard" class="hash-link" aria-label="Direct link to Step 3 — Run the application wizard" title="Direct link to Step 3 — Run the application wizard" translate="no">​</a></h2>
<p><strong>Applications → Applications → Create</strong>. authentik walks you through five steps, and
the step list on the left is worth reading before you touch anything:</p>
<p><strong>Application → Choose a Provider → Configure Provider → Configure Bindings → Review and
Submit</strong></p>
<p>That's the vocabulary from earlier, in order. The wizard creates the application and its
provider in one pass and pairs them for you. (<code>Applications → Providers → Create</code> builds
a provider on its own, if you ever need one before the app that uses it.)</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="31--application">3.1 — Application<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#31--application" class="hash-link" aria-label="Direct link to 3.1 — Application" title="Direct link to 3.1 — Application" translate="no">​</a></h3>
<ul>
<li class=""><strong>Application Name</strong> — <code>Langfuse</code>. The label users see on the dashboard.</li>
<li class=""><strong>Slug</strong> — <code>langfuse</code>. Load-bearing: it becomes part of the issuer URL, so changing it
later changes the URL you have to reconfigure Langfuse with.</li>
<li class=""><strong>Group</strong> — leave empty. This groups apps on the dashboard; it has nothing to do with
user groups or access control, despite the name.</li>
<li class=""><strong>Policy engine mode</strong> — leave on <strong>ANY</strong>. It decides how multiple bound policies
combine. With no policies bound, it makes no difference yet.</li>
</ul>
<p><span class="zoomImage__wrap"><img alt="The wizard&amp;#39;s Application step, showing the Name, Slug, Group and Policy engine mode fields before anything is entered" src="https://development-wec.wiline.com/docs/assets/images/authentik-wizard-application-62b3555c7b9b05dedfdd535153d2c084.png" width="1901" height="960" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="32--choose-a-provider">3.2 — Choose a Provider<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#32--choose-a-provider" class="hash-link" aria-label="Direct link to 3.2 — Choose a Provider" title="Direct link to 3.2 — Choose a Provider" translate="no">​</a></h3>
<p>Eight provider types. Take <strong>OAuth2/OpenID Provider</strong> — "OAuth2 Provider for generic
OAuth and OpenID Connect Applications."</p>
<p><span class="zoomImage__wrap"><img alt="The wizard&amp;#39;s provider type step with eight tiles and OAuth2/OpenID Provider selected" src="https://development-wec.wiline.com/docs/assets/images/authentik-wizard-provider-type-62af4193167bfaa5359808b786af8d41.png" width="1898" height="958" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Worth knowing what you're <em>not</em> picking, because two of these solve a different problem:
<strong>Proxy Provider</strong> puts authentik in front of an app that has no SSO support of its own,
and <strong>LDAP Provider</strong> exposes authentik to things that only speak LDAP. Langfuse speaks
OIDC natively, so it gets a real OIDC provider.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="33--configure-provider">3.3 — Configure Provider<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#33--configure-provider" class="hash-link" aria-label="Direct link to 3.3 — Configure Provider" title="Direct link to 3.3 — Configure Provider" translate="no">​</a></h3>
<p>The step that matters, and the one that will cost you time if you rush it.</p>
<ul>
<li class="">
<p><strong>Authorization flow</strong> — <code>default-provider-authorization-explicit-consent</code>. "Explicit"
shows the user a consent screen listing what Langfuse asked for. The implicit variant
skips it. Pick explicit for a first build: that screen is a live readout of your scope
configuration, which makes a misconfiguration visible instead of silent.</p>
</li>
<li class="">
<p><strong>Client type</strong> — <strong>Confidential</strong> (scrolled above the redirect fields). Langfuse is a
server and can keep a secret, so it gets one. Public clients are for browsers and
mobile apps that can't.</p>
</li>
<li class="">
<p><strong>Redirect URIs/Origins (RegEx)</strong> — the field with two dropdowns and a value. Set the
mode to <strong>Strict</strong>, the purpose to <strong>Authorization</strong>, and the URI to Langfuse's
NextAuth callback:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">http://10.80.4.212:3001/api/auth/callback/custom</span><br></div></code></pre></div></div>
<p>The trailing <code>custom</code> is not a placeholder — it's the provider ID NextAuth assigns to
its generic OIDC provider. Use your own host and port, but that path is fixed.</p>
</li>
<li class="">
<p><strong>Signing Key</strong> — leave <code>authentik Self-signed Certificate</code>. This signs the ID token;
Langfuse fetches the matching public key from the <code>jwks_uri</code> in Step 5 and verifies
against it. Self-signed is correct here — the RP trusts this key because discovery
handed it over, not because a CA vouched for it.</p>
</li>
<li class="">
<p><strong>Logout URI</strong> and token validities — leave alone.</p>
</li>
</ul>
<p>The provider once it's saved — explicit-consent authorization flow, Confidential client
type, and Authorization Code as the grant:</p>
<p><span class="zoomImage__wrap"><img alt="The OAuth2/OpenID provider edit form showing the explicit-consent authorization flow, Client Type set to Confidential, and Authorization Code checked" src="https://development-wec.wiline.com/docs/assets/images/authentik-wizard-configure-provider-11b9bd02c65bf52f23d2afe34f3a8be9.png" width="1896" height="948" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Strict means strict</div><div class="admonitionContent_BuS1"><p>The mode dropdown offers <strong>Regex</strong> as well, and authentik's own help text points out you
can set it to <code>.*</code> to allow any redirect URI. Don't. An open redirect URI means anyone
who can start a login can have the authorization code delivered to a host they control.
Strict, with one exact URL, is the whole point of the field.</p></div></div>
<p>Further down, <strong>Scopes</strong> comes pre-populated with four mappings: <code>email</code>, <code>openid</code>,
<code>profile</code> and <code>offline_access</code>. Leave them. <code>openid</code> is mandatory and is what marks this
as OIDC rather than bare OAuth2. <code>email</code> is the claim Langfuse keys the account on.
<code>profile</code> carries the display name. <code>offline_access</code> is the one the wizard adds without
asking — it permits a refresh token, so a session can be renewed without sending the user
back through the login flow.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="34--configure-bindings">3.4 — Configure Bindings<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#34--configure-bindings" class="hash-link" aria-label="Direct link to 3.4 — Configure Bindings" title="Direct link to 3.4 — Configure Bindings" translate="no">​</a></h3>
<p><code>No bound policies.</code> — and we are leaving it that way.</p>
<p><span class="zoomImage__wrap"><img alt="The wizard&amp;#39;s Configure Bindings step reading No bound policies" src="https://development-wec.wiline.com/docs/assets/images/authentik-wizard-bindings-b5cb6f9b81084f2eb498d0b4cdeae6cd.png" width="1896" height="954" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>This is the gap, and it's deliberate</div><div class="admonitionContent_BuS1"><p>Read the wizard's own description: <em>"These policies control which users can access this
application."</em> With nothing bound, the answer is <strong>everyone who can authenticate</strong>. On a
box where you're the only account, that's the same thing as "just me" — which is why
it's tolerable here and why the wizard lets you skip it.</p><p>It is not access control. Every account you ever create in this authentik gets into
Langfuse by default, and nothing will warn you. Groups and policy bindings are how that
gets fixed, and they're the subject of the next post — where the thing being protected
is a gateway and the answer matters a great deal more.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="35--review-and-submit">3.5 — Review and Submit<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#35--review-and-submit" class="hash-link" aria-label="Direct link to 3.5 — Review and Submit" title="Direct link to 3.5 — Review and Submit" translate="no">​</a></h3>
<p>Check the slug and the redirect URI once more, then submit.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--read-the-configuration-back-out-of-authentik">Step 4 — Read the configuration back out of authentik<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-4--read-the-configuration-back-out-of-authentik" class="hash-link" aria-label="Direct link to Step 4 — Read the configuration back out of authentik" title="Direct link to Step 4 — Read the configuration back out of authentik" translate="no">​</a></h2>
<p>Rather than assembling URLs by hand, ask authentik. Every OIDC provider publishes a
discovery document, and authentik's lives under the application slug:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, grant_types_supported}'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"issuer"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/langfuse/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"authorization_endpoint"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/authorize/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"token_endpoint"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/token/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"userinfo_endpoint"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/userinfo/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"jwks_uri"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://10.80.4.212:9100/application/o/langfuse/jwks/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"grant_types_supported"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"authorization_code"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"refresh_token"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"implicit"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"client_credentials"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"password"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"urn:ietf:params:oauth:grant-type:device_code"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Two things to take from this. The <strong>issuer</strong> is the only URL Langfuse needs — it will
fetch this same document and discover the rest. And <code>grant_types_supported</code> lists what
the protocol offers, not what your provider will accept; we're using
<code>authorization_code</code> and the presence of <code>password</code> in that list is not an invitation.</p>
<p><span class="zoomImage__wrap"><img alt="The discovery document returned by the running authentik, showing the issuer, the authorize, token and userinfo endpoints, the JWKS URL and the supported grant types" src="https://development-wec.wiline.com/docs/assets/images/authentik-discovery-json-959875837069e04a0b42b7cc81ad0b75.png" width="860" height="313" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Now the client credentials. They're in the provider's detail page in the UI, but
<code>docker compose exec</code> is faster and copy-pastes cleanly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/authentik </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> server ak shell </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from authentik.core.models import Application</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">p = Application.objects.get(slug='langfuse').get_provider()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('PROVIDER     :', p.name)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('CLIENT_ID    :', p.client_id)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print('CLIENT_SECRET:', p.client_secret)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<p>Note that this looks the provider up <em>through the application slug</em> rather than by
provider name. The wizard named your provider <code>Provider for Langfuse</code>, not <code>langfuse</code> —
the slug is the thing you chose and the thing that stays predictable. <code>get_provider()</code>
also matters: reaching for <code>application.provider</code> hands back the base class with
<code>client_id</code> empty, which looks alarmingly like a broken provider and isn't.</p>
<p>Keep that output out of your shell history and out of screenshots.</p>
<p>The provider's detail page carries the same values, if you'd rather read them there —
client type, client ID, the strict redirect URI, and every discovery URL in one place.
The client secret is the one thing it doesn't print:</p>
<p><span class="zoomImage__wrap"><img alt="The Provider for Langfuse overview page listing client type, client ID, the strict redirect URI and the OpenID configuration, authorize, token, userinfo and JWKS URLs" src="https://development-wec.wiline.com/docs/assets/images/authentik-provider-overview-5b451791d184236f85921a5a394f956e.png" width="1897" height="952" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--point-langfuse-at-it">Step 5 — Point Langfuse at it<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-5--point-langfuse-at-it" class="hash-link" aria-label="Direct link to Step 5 — Point Langfuse at it" title="Direct link to Step 5 — Point Langfuse at it" translate="no">​</a></h2>
<p>Langfuse reads generic OIDC settings from <code>AUTH_CUSTOM_*</code> variables. Add them to
<code>~/langfuse/.env</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">AUTH_CUSTOM_NAME</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">authentik</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">AUTH_CUSTOM_ISSUER</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">http://10.80.4.212:9100/application/o/langfuse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">AUTH_CUSTOM_CLIENT_ID</span><span class="token operator" style="color:#393A34">=</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">the client </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> from Step </span><span class="token operator file-descriptor important" style="color:#393A34">4</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">AUTH_CUSTOM_CLIENT_SECRET</span><span class="token operator" style="color:#393A34">=</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">the client secret from Step </span><span class="token operator file-descriptor important" style="color:#393A34">4</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">AUTH_CUSTOM_SCOPE</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">openid email profile</span><br></div></code></pre></div></div>
<p>The issuer here has no trailing slash while the discovery document reported one. Both
work — Langfuse normalizes it.</p>
<p>Also confirm <code>NEXTAUTH_URL</code> matches the address you actually browse to:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> NEXTAUTH_URL ~/langfuse/.env</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NEXTAUTH_URL=http://10.80.4.212:3001</span><br></div></code></pre></div></div>
<p>If this says <code>localhost</code> while your redirect URI says <code>10.80.4.212</code>, the callback will
be built with the wrong hostname and authentik will reject it.</p>
<p><strong>Now the part that will waste your afternoon if you skip it.</strong> Writing those variables
into <code>.env</code> is not enough. Langfuse's shipped compose file lists the variables it passes
into the <code>langfuse-web</code> container explicitly, and <code>AUTH_CUSTOM_*</code> isn't among them —
they land in <code>.env</code>, get read by Compose for interpolation, and never reach the process.
Langfuse starts fine and shows no SSO button, with nothing in the logs to say why.</p>
<p>Pass them through with an override file, <code>~/langfuse/docker-compose.override.yml</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">langfuse-web</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_NAME</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">AUTH_CUSTOM_NAME</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_ISSUER</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">AUTH_CUSTOM_ISSUER</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_CLIENT_ID</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">AUTH_CUSTOM_CLIENT_ID</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_CLIENT_SECRET</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">AUTH_CUSTOM_CLIENT_SECRET</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_SCOPE</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">AUTH_CUSTOM_SCOPE</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"true"</span><br></div></code></pre></div></div>
<p>Compose merges <code>docker-compose.override.yml</code> automatically, so the upstream file stays
untouched and survives upgrades. That last variable is the fix for a failure you'd
otherwise hit in the next step — the troubleshooting section explains what it does and
what it costs.</p>
<p>Recreate the web container and verify the variables arrived:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/langfuse </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> langfuse-web</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/langfuse </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-web </span><span class="token function" style="color:#d73a49">env</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> AUTH_CUSTOM</span><br></div></code></pre></div></div>
<p>Six. If it's zero, the override isn't being picked up — check you're running compose
from <code>~/langfuse</code> and that the filename is exactly <code>docker-compose.override.yml</code>.</p>
<p><span class="zoomImage__wrap"><img alt="docker compose exec counting six AUTH_CUSTOM variables inside the langfuse-web container" src="https://development-wec.wiline.com/docs/assets/images/langfuse-env-count-c57cd6125a9c2dbc129ef850beee6378.png" width="477" height="82" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--sign-in">Step 6 — Sign in<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#step-6--sign-in" class="hash-link" aria-label="Direct link to Step 6 — Sign in" title="Direct link to Step 6 — Sign in" translate="no">​</a></h2>
<p>Open <code>http://10.80.4.212:3001/auth/sign-in</code>, in a private window so you're not looking at
an existing session. There's a new button below the divider under the password form.</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse sign-in page with an authentik button below the email and password form" src="https://development-wec.wiline.com/docs/assets/images/langfuse-sso-button-40985ca287c0686b8a2c65f8b7998d3d.png" width="2838" height="1634" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The button is labelled <code>authentik</code> because that is your <code>AUTH_CUSTOM_NAME</code> value, rendered
verbatim. Set it to <code>Company SSO</code> and that's what the button says — it's a display string
and nothing else depends on it.</p>
<p>Two other things in that screenshot are worth naming rather than glossing over. The
email-and-password form is still there, and so is <strong>Sign up</strong> — SSO has been <em>added</em>, not
substituted. And the address bar says <strong>Not Secure</strong>, correctly: this is plain HTTP.</p>
<p>Click the button and you're handed to authentik, which tells you where you came from:</p>
<p><span class="zoomImage__wrap"><img alt="The authentik login screen reading Log in to continue to Langfuse" src="https://development-wec.wiline.com/docs/assets/images/authentik-login-langfuse-f20d6f5f65651cb88e1db77bdd2f7c07.png" width="1900" height="987" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Log in, and because we chose the explicit-consent flow in Step 3.3, authentik shows what
Langfuse asked for before it hands anything over:</p>
<p><span class="zoomImage__wrap"><img alt="The authentik consent screen, Redirecting to Langfuse, listing Email address and General Profile Information" src="https://development-wec.wiline.com/docs/assets/images/authentik-consent-1db931f804b8b9ddaa8562e01b588adf.png" width="1900" height="990" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>It reads <code>You're about to sign into Langfuse</code>, identifies you as <code>akadmin</code>, and lists two
permissions — <strong>Email address</strong> and <strong>General Profile Information</strong>. Those are the <code>email</code>
and <code>profile</code> scopes in human-readable form. <code>openid</code> doesn't get a line of its own: it
carries no personal data to consent to, it's the flag that makes this OIDC rather than
plain OAuth2. So a three-scope configuration produces a two-item consent screen, which is
correct and not a sign that something dropped.</p>
<p>Continue, and you land in Langfuse, signed in, as the identity authentik vouched for.</p>
<p>That's the loop closed. Langfuse never saw a password.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-errors-this-run-actually-produced">Troubleshooting — the errors this run actually produced<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#troubleshooting--the-errors-this-run-actually-produced" class="hash-link" aria-label="Direct link to Troubleshooting — the errors this run actually produced" title="Direct link to Troubleshooting — the errors this run actually produced" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="oauthaccountnotlinked"><code>OAuthAccountNotLinked</code><a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#oauthaccountnotlinked" class="hash-link" aria-label="Direct link to oauthaccountnotlinked" title="Direct link to oauthaccountnotlinked" translate="no">​</a></h3>
<p>The first sign-in attempt bounced back to the Langfuse login page with
<code>?error=OAuthAccountNotLinked</code> in the URL and nothing more.</p>
<p>The cause: an email-and-password Langfuse account already existed with the same address
authentik was now presenting. NextAuth's default is to refuse the merge. That default is
protective — if an identity provider can assert any email and the app auto-links on
email alone, then whoever controls the provider can take over an existing account by
claiming its address.</p>
<p><code>AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: "true"</code> tells Langfuse to link them anyway. It is
safe <strong>here</strong> because you own the only provider and you control who can create accounts
in it. It would not be safe pointed at a provider where anyone can self-register an
arbitrary email.</p>
<p>The alternative, if you'd rather not enable it: delete the pre-existing local account
and let SSO create a fresh one.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="redirect_uri-mismatch-or-authentik-refusing-to-redirect"><code>redirect_uri</code> mismatch, or authentik refusing to redirect<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#redirect_uri-mismatch-or-authentik-refusing-to-redirect" class="hash-link" aria-label="Direct link to redirect_uri-mismatch-or-authentik-refusing-to-redirect" title="Direct link to redirect_uri-mismatch-or-authentik-refusing-to-redirect" translate="no">​</a></h3>
<p>Read the failing URL in the address bar and compare it to the provider's redirect URI
character by character. In this run the mismatches worth naming were the trailing
<code>/api/auth/callback/custom</code> path (any other suffix fails), <code>localhost</code> versus the LAN IP,
and a stray trailing slash. Strict matching means strict.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="no-sso-button-no-errors">No SSO button, no errors<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#no-sso-button-no-errors" class="hash-link" aria-label="Direct link to No SSO button, no errors" title="Direct link to No SSO button, no errors" translate="no">​</a></h3>
<p>That's the compose-environment trap from Step 5. Check the variables reached the
container:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/langfuse </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-web </span><span class="token function" style="color:#d73a49">env</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> AUTH_CUSTOM_ISSUER</span><br></div></code></pre></div></div>
<p>Empty output means Langfuse is running without the config it never knew it was missing.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="discovery-fails-from-inside-the-container">Discovery fails from inside the container<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#discovery-fails-from-inside-the-container" class="hash-link" aria-label="Direct link to Discovery fails from inside the container" title="Direct link to Discovery fails from inside the container" translate="no">​</a></h3>
<p>Your browser reaching authentik is not proof that Langfuse can. The token exchange is
container-to-container:</p>
<p>The <code>langfuse-web</code> image ships no <code>curl</code>, so use the Node runtime that's already in
there — <code>fetch</code> is built in:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/langfuse </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-web </span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"fetch('http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration').then(r=&gt;console.log(r.status)).catch(e=&gt;console.log('FAIL',e.message))"</span><br></div></code></pre></div></div>
<p><code>200</code> is what you want. <code>FAIL</code> with a connection error means the container can't reach
authentik at all, which is the actual thing being tested. This is also why the issuer is a LAN IP rather than <code>localhost</code>
— inside the Langfuse container, <code>localhost</code> is the Langfuse container.</p>
<p><span class="zoomImage__wrap"><img alt="The node fetch call run inside langfuse-web printing 200" src="https://development-wec.wiline.com/docs/assets/images/langfuse-discovery-200-f17cc0564e966cf97ba8d8a4cc9e7c9f.png" width="533" height="120" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can run your own identity provider and put an app behind it over OIDC — reading the
issuer, client ID and secret out of authentik rather than assembling URLs by hand, and
recognising the two failures that actually stop you: a compose file that never passes the
variables through, and <code>OAuthAccountNotLinked</code>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-did-and-didnt-buy-you">What this did and didn't buy you<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#what-this-did-and-didnt-buy-you" class="hash-link" aria-label="Direct link to What this did and didn't buy you" title="Direct link to What this did and didn't buy you" translate="no">​</a></h2>
<p>Done: one identity provider, one place where accounts live, one place to revoke them.
Langfuse no longer stores a password for you. And you now have a provider you can point
the next app at in about five minutes.</p>
<p>Not done, and worth being clear about:</p>
<ul>
<li class=""><strong>No access control.</strong> Empty bindings mean every authentik account reaches Langfuse.</li>
<li class=""><strong>Plain HTTP.</strong> The ID token and the client secret cross the network unencrypted. On a
trusted LAN that's a tolerable starting point; exposed to anything else it isn't.</li>
<li class=""><strong>One admin, no recovery path.</strong> If you lose <code>akadmin</code>, you lose the provider that
fronts everything pointed at it.</li>
<li class=""><strong>The old login still works.</strong> Password sign-in wasn't disabled, so SSO is currently an
additional door, not a replacement one.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Identity in front of every port</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Part 4 puts identity in front of something where these gaps stop being tolerable: the
LiteLLM gateway from the <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">gateway series</a>.
Three things this post deliberately left alone:</p>
<ul>
<li class=""><strong>The control plane is not the data plane.</strong> SSO protects an admin UI. It does not
protect an API — machine callers still authenticate with keys. For a gateway that
distinction is the whole game, and assuming otherwise is how people conclude they've
secured something they haven't.</li>
<li class=""><strong>Groups and policy bindings</strong>, so that reaching the application requires membership
rather than merely having an account.</li>
<li class=""><strong>TLS and real exposure</strong> — the gateway is bound to <code>127.0.0.1:4000</code>, and getting it
properly reachable means a reverse proxy, a hostname and a certificate.</li>
</ul>
<p>The provider-and-application steps will be a short recap with a link back here. The rest
is new.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.goauthentik.io/docs/add-secure-apps/providers/oauth2/" target="_blank" rel="noopener noreferrer" class="">authentik docs — OAuth2/OpenID provider</a></li>
<li class=""><a href="https://docs.goauthentik.io/docs/add-secure-apps/applications/" target="_blank" rel="noopener noreferrer" class="">authentik docs — applications, and binding policies to them</a></li>
<li class=""><a href="https://langfuse.com/self-hosting/authentication-and-sso" target="_blank" rel="noopener noreferrer" class="">Langfuse docs — SSO and the <code>AUTH_CUSTOM_*</code> variables</a></li>
<li class=""><a href="https://next-auth.js.org/configuration/providers/oauth#allowdangerousemailaccountlinking-option" target="_blank" rel="noopener noreferrer" class="">NextAuth — account linking, and why <code>OAuthAccountNotLinked</code> exists</a></li>
<li class=""><a href="https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth" target="_blank" rel="noopener noreferrer" class="">OpenID Connect Core — the authorization code flow</a></li>
</ul>]]></content:encoded>
            <category>security</category>
            <category>sso</category>
            <category>oidc</category>
            <category>authentik</category>
            <category>identity</category>
            <category>langfuse</category>
            <category>self-hosting</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Load test an LLM gateway and find the stall the median hides]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/</guid>
            <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Part 4 measured a blocking call in a callback at 200-350ms and said the real damage only shows under concurrency. This runs it: sixteen requests at once, with a control against the upstream so you can tell your gateway's fault from the model's. The medians of the two versions are almost identical. One version drops requests and silently stops masking.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"><span class="tutorialHero__plus">+</span><img class="tutorialHero__presidio" src="data:image/webp;base64,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" alt="Presidio"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 5 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->5</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->5<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting an LLM gateway</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">One endpoint, scoped keys</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Route work to the right model</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Mask PII at the gateway</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Clean traces, untouched answers</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">Prove it holds under load</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/">Part 4</a> built a callback that masks
PII in what the gateway logs without touching what the caller receives. It also noted
that the first working version used a blocking HTTP client inside an <code>async def</code>, and
that this cost 200-350ms across four sequential requests.</p>
<p>That post ended with an admission: four requests in a row is the wrong test. A blocked
event loop barely shows when nothing else is waiting. The damage should appear under
concurrency, and we had not measured it.</p>
<p>This is that measurement, and the result is a shape rather than a number. <strong>With the
blocking client, some batches take many times longer than they should. With the async
client, none do.</strong> The medians barely differ, which is exactly why this ships unnoticed.</p>
<p>Two things turned up that were not in the plan. The gateway does not merely slow down —
it <strong>drops requests</strong>. And when it does, the masking call is the thing that timed out,
while the caller still gets <code>200 OK</code>. That means the guarantee Part 4 was built on
quietly stops holding under load.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-need">What you need<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#what-you-need" class="hash-link" aria-label="Direct link to What you need" title="Direct link to What you need" translate="no">​</a></h2>
<ul>
<li class="">The gateway from <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1</a>, running</li>
<li class="">Presidio and the logging callback from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/">Part 4</a></li>
<li class="">A WEC Inference API key — <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">create one</a> in
the portal</li>
<li class=""><code>httpx</code> in your Python environment</li>
</ul>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Where these numbers came from</div><div class="admonitionContent_BuS1"><p>One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. LiteLLM
<code>1.96.2</code> from <code>ghcr.io/berriai/litellm:main-stable</code>. Presidio analyzer and anonymizer as
local containers on the same host. Model <code>Qwen2.5-3B-Instruct</code> over the WEC Inference
API. Five repetitions per cell.</p><p>Latency numbers do not transfer between machines. Treat every second in this post as one
observation from that setup, not a figure to match. What should reproduce is the
difference between the two versions.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="first-find-out-how-many-event-loops-you-have">First, find out how many event loops you have<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#first-find-out-how-many-event-loops-you-have" class="hash-link" aria-label="Direct link to First, find out how many event loops you have" title="Direct link to First, find out how many event loops you have" translate="no">​</a></h2>
<p>Everything here depends on one number, and it is not the number the documentation gives.</p>
<p>The LiteLLM proxy CLI has a <code>--num_workers</code> flag. The
<a href="https://docs.litellm.ai/docs/proxy/cli" target="_blank" rel="noopener noreferrer" class="">published reference</a> says its default is
<em>"Number of logical CPUs in the system, or 4 if that cannot be determined."</em> On an
eight-core box that would mean eight worker processes, eight event loops, and a blocking
call stalling one eighth of your traffic.</p>
<p>Ask the host what is actually running — from outside the container, so nothing inside it
can misreport:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">top</span><span class="token plain"> llm-gateway</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">UID    PID       PPID      C  STIME  TTY  TIME      CMD</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">root   2477458   2477435   3  16:43  ?    00:01:19  /app/.venv/bin/python3 /app/.venv/bin/litellm --config /app/config.yaml --port 4000</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">root   2481091   2477458   0  16:44  ?    00:00:05  /opt/prisma/binaries/node_modules/prisma/query-engine-debian-openssl-3.0.x -p 53053</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker top showing one litellm python process and one Prisma query engine" src="https://development-wec.wiline.com/docs/assets/images/gw5-one-process-a28a66c6eafcb70fa88a41c347ddd22d.png" width="643" height="182" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 1.</strong> One python process runs the whole proxy. The second entry is Prisma, the
database client, which does not serve requests.</p>
<p>If <code>--num_workers</code> were greater than one you would see several python processes with the
first one as their parent. Confirm it in the startup log:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs llm-gateway </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Started server process|Uvicorn running"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-2</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     Started server process [1]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     Uvicorn running on http://0.0.0.0:4000 (Press CTRL+C to quit)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The startup log showing a single server process and uvicorn bound to port 4000" src="https://development-wec.wiline.com/docs/assets/images/gw5-startup-log-df1d1c66b00b54c6d3cca0e6787ae4eb.png" width="645" height="87" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 2.</strong> One server process. This log accumulates across restarts, so <code>tail -2</code>
keeps you looking at the current one.</p>
<p>Now ask the tool what its default is:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway litellm </span><span class="token parameter variable" style="color:#36acaa">--help</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-A5</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"^  --num_workers"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  --num_workers INTEGER           Number of worker processes for uvicorn /</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                  gunicorn, or Granian worker processes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                  (--workers). Default is 1 (from</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                  DEFAULT_NUM_WORKERS_LITELLM_PROXY). With</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                  --run_granian, use --granian_threads for</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                  runtime threads per worker.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The litellm help output with the phrase Default is 1 highlighted" src="https://development-wec.wiline.com/docs/assets/images/gw5-worker-default-a862f11836711c637727b9c24b5d3b0a.png" width="922" height="125" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 3.</strong> The tool's own help text: <em>"Default is 1."</em></p>
<p>That is a help string. Since we are about to contradict the published documentation, take
it from the source too — adjust the python version in the path to match your image:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-rn</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'DEFAULT_NUM_WORKERS_LITELLM_PROXY'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  /app/.venv/lib/python3.13/site-packages/litellm/constants.py</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">15:DEFAULT_NUM_WORKERS_LITELLM_PROXY = int(os.getenv("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1))</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The published reference is wrong about this</div><div class="admonitionContent_BuS1"><p>The <a href="https://docs.litellm.ai/docs/proxy/cli" target="_blank" rel="noopener noreferrer" class="">CLI reference</a> states the <code>--num_workers</code>
default as <em>"Number of logical CPUs in the system, or 4 if that cannot be determined."</em>
Three sources say otherwise: the running container, the installed <code>1.96.2</code> source, and
current upstream <code>main</code>, where both the constant and the help string still say <strong>1</strong>.</p><p>It is not a version skew, and it is not something in this deployment. Check yours:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway </span><span class="token function" style="color:#d73a49">env</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> worker</span><br></div></code></pre></div></div><p>Empty output means you are on the default, which is one worker.</p></div></div>
<p>So: one process, one event loop, shared by every request in flight. That is the condition
that turns a blocking call from a tax into an outage.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-load-script">A load script<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#a-load-script" class="hash-link" aria-label="Direct link to A load script" title="Direct link to A load script" translate="no">​</a></h2>
<p>Fire N requests at once, time each, report the wall clock for the batch. The message
carries PII so the masking callback has real work to do. A failed request is counted
rather than allowed to abandon the run — you will need that.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/llm-gateway/loadtest.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""Fire N chat completions at the gateway at once and report how long they take.</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="display:inline-block;color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">The message carries PII so the Presidio masking callback has real work to do.</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">A failed request is counted and reported, not allowed to abandon the batch.</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> asyncio</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> statistics</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> sys</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> time</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> httpx</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GATEWAY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://127.0.0.1:4000/v1/chat/completions"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"LITELLM_MASTER_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MODEL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"qwen-small"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">PROMPT </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"Priya Raghunathan called from 415-555-0134 about her invoice. "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"Reply with exactly: ok"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">one</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    start </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">perf_counter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">try</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            GATEWAY</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            headers</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"Authorization"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Bearer </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">KEY</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> MODEL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> PROMPT</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token string" style="color:#e3116c">"max_tokens"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">except</span><span class="token plain"> Exception </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> exc</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">type</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">exc</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">__name__</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">status_code</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">perf_counter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> start</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    n </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> httpx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">AsyncClient</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">120.0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        wall </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">perf_counter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        results </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">gather</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">*</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">one</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> i </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token builtin">range</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">n</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        wall </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">perf_counter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> wall</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    times </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">sorted</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">e </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> _</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> _</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> e</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> _ </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> results </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> e </span><span class="token keyword" style="color:#00009f">is</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    errors </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">err </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> _</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> _</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> _</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> err </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> results </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> err</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"concurrency </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">n</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  ok </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation builtin">len</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">times</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  failed </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation builtin">len</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">errors</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> errors</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  errors   </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation string" style="color:#e3116c">', '</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">join</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation builtin">sorted</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation builtin">set</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">errors</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  wall     </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">wall</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">s"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> times</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  fastest  </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">times</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation number" style="color:#36acaa">0</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">s"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  median   </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">statistics</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">median</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">times</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">s"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  slowest  </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">times</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation operator" style="color:#393A34">-</span><span class="token string-interpolation interpolation number" style="color:#36acaa">1</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">.2f</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">s"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">asyncio</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> ./.env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python loadtest.py </span><span class="token number" style="color:#36acaa">8</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">concurrency 8  ok 8  failed 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  wall     1.93s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  fastest  1.60s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  median   1.91s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  slowest  1.93s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Eight concurrent requests through the gateway, all succeeding, batch wall time 1.93 seconds" src="https://development-wec.wiline.com/docs/assets/images/gw5-first-load-be7eeba783710fd2f310b7add1c21fdc.png" width="645" height="120" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 4.</strong> Eight at once, none failed. On its own this number means nothing yet.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="you-need-a-control-or-your-numbers-mean-nothing">You need a control, or your numbers mean nothing<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#you-need-a-control-or-your-numbers-mean-nothing" class="hash-link" aria-label="Direct link to You need a control, or your numbers mean nothing" title="Direct link to You need a control, or your numbers mean nothing" translate="no">​</a></h2>
<p>Here is the trap. The gateway calls a model over the network. That model has its own
latency, its own load, and its own bad afternoons. When a batch takes twelve seconds you
cannot tell whether your gateway stalled or the upstream was busy — and if you guess, you
will publish nonsense.</p>
<p>So measure the upstream directly, with the gateway out of the path. Copy the file and
change the three constants at the top:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cp</span><span class="token plain"> loadtest.py loadtest_direct.py</span><br></div></code></pre></div></div>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/llm-gateway/loadtest_direct.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">GATEWAY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1/chat/completions"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MODEL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python loadtest_direct.py </span><span class="token number" style="color:#36acaa">8</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">concurrency 8  ok 8  failed 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  wall     1.15s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  fastest  1.05s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  median   1.10s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  slowest  1.15s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Eight concurrent requests straight to the upstream, batch wall time 1.15 seconds" src="https://development-wec.wiline.com/docs/assets/images/gw5-control-6c9353892f98ded0047bab740eae67a0.png" width="643" height="126" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 5.</strong> The same eight requests with the gateway removed. This is the baseline
every gateway number gets read against.</p>
<p>Now run the two <strong>interleaved</strong>, not one after the other, so a slow minute upstream
cannot land entirely on one version:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/llm-gateway/run_experiment.sh</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token shebang important">#!/bin/bash</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Interleave gateway runs with direct-to-upstream control runs so an upstream</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># slowdown cannot be mistaken for a gateway effect.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">set</span><span class="token plain"> -a</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> ./.env</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">REPS</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">5</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># A restart takes longer than it looks. Firing requests at a port that is not</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># answering yet produces instant failures that have nothing to do with the callback.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'waiting for the gateway'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">_</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">seq</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">1</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">60</span><span class="token variable" style="color:#36acaa">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sf</span><span class="token plain"> http://127.0.0.1:4000/health/liveliness </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">break</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.'</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sf</span><span class="token plain"> http://127.0.0.1:4000/health/liveliness </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">" never came up — aborting"</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">exit</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">" up"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Do not trust a label passed on the command line — ask the gateway which callback</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># it actually loaded. A mislabelled run is worse than no run.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">LABEL</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">docker</span><span class="token variable" style="color:#36acaa"> logs llm-gateway </span><span class="token variable operator file-descriptor important" style="color:#393A34">2</span><span class="token variable operator" style="color:#393A34">&gt;</span><span class="token variable file-descriptor important" style="color:#36acaa">&amp;1</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'\[scrubber\]'</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">tail</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-1</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">sed</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'s/.*loaded: //'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-z</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$LABEL</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">then</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"cannot determine loaded callback — aborting"</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">exit</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">fi</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"callback in use: </span><span class="token string variable" style="color:#36acaa">$LABEL</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function-name function" style="color:#d73a49">summarise</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">awk</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'/^concurrency/{failed=$6} /wall/{wall=$2} END{printf "%s", wall; if (failed+0 &gt; 0) printf "(%d failed)", failed}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># The first request after a restart pays for imports and connection pools, which</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># has nothing to do with the callback. Warm both paths before recording anything.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python loadtest.py </span><span class="token number" style="color:#36acaa">4</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python loadtest_direct.py </span><span class="token number" style="color:#36acaa">4</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">n</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">8</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">16</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">i</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">seq</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">1</span><span class="token variable" style="color:#36acaa"> $REPS</span><span class="token variable" style="color:#36acaa">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token assign-left variable" style="color:#36acaa">g</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/.venv/bin/python loadtest.py $n </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> summarise</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token assign-left variable" style="color:#36acaa">d</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">~/.venv/bin/python loadtest_direct.py $n </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> summarise</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$LABEL</span><span class="token string" style="color:#e3116c"> N=</span><span class="token string variable" style="color:#36acaa">$n</span><span class="token string" style="color:#e3116c"> rep=</span><span class="token string variable" style="color:#36acaa">$i</span><span class="token string" style="color:#e3116c"> gateway=</span><span class="token string variable" style="color:#36acaa">$g</span><span class="token string" style="color:#e3116c"> direct=</span><span class="token string variable" style="color:#36acaa">$d</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><br></div></code></pre></div></div>
<p>Three details in there are not decoration, and each one cost a wasted run to learn:</p>
<p><strong>It waits for the gateway.</strong> A <code>docker compose restart</code> can take longer than ten
seconds, and firing at a port that is not listening yet produces instant failures —
<code>gateway=0.03s(8 failed)</code> — that look like a catastrophic result and mean nothing.</p>
<p><strong>It refuses to take a label from you.</strong> It reads the loaded callback out of the
gateway's log. Pass <code>blocking</code> on the command line while the async callback is loaded and
you will measure the same code twice, see no difference, and conclude there is nothing
here. That happened twice while writing this.</p>
<p><strong>It throws away a warm-up pass.</strong> The first batch after a restart pays for imports and
connection pools, and comes in three to six times slower than the next one regardless of
which callback is loaded.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-each-version-announce-itself">Make each version announce itself<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#make-each-version-announce-itself" class="hash-link" aria-label="Direct link to Make each version announce itself" title="Direct link to Make each version announce itself" translate="no">​</a></h2>
<p>You are about to compare two versions of one file, and you need certainty about which is
live. LiteLLM's startup log lists success and failure callbacks but not
<code>litellm_settings.callbacks</code>, and there is no endpoint that reports them —
<code>/get/config/callbacks</code> answers <code>200</code> with <code>{"detail":"Not Found"}</code>.</p>
<p>So have each version say its own name at import, where it costs nothing per request:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># last line of scrubber.py</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"[scrubber] loaded: async httpx.AsyncClient"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># last line of scrubber_blocking.py</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"[scrubber] loaded: blocking httpx.Client"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Mount both files, and switch with one line of config:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./scrubber.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/scrubber.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./scrubber_blocking.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/scrubber_blocking.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./recognizers.json</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/recognizers.json</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">litellm_settings</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">callbacks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scrubber.instance"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">          </span><span class="token comment" style="color:#999988;font-style:italic"># or scrubber_blocking.instance</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/scrubber.instance/scrubber_blocking.instance/'</span><span class="token plain"> config.yaml</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart litellm</span><br></div></code></pre></div></div>
<p>The experiment script prints the loaded callback as its first line, so every run carries
its own proof of what it measured.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-numbers">The numbers<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#the-numbers" class="hash-link" aria-label="Direct link to The numbers" title="Direct link to The numbers" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">./run_experiment.sh</span><br></div></code></pre></div></div>
<p>With the async client:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">callback in use: async httpx.AsyncClient</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=8 rep=1 gateway=0.98s direct=1.09s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=8 rep=2 gateway=0.86s direct=0.80s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=8 rep=3 gateway=0.92s direct=0.96s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=8 rep=4 gateway=0.78s direct=0.85s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=8 rep=5 gateway=0.90s direct=0.82s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=16 rep=1 gateway=1.61s direct=1.49s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=16 rep=2 gateway=1.65s direct=1.26s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=16 rep=3 gateway=1.28s direct=1.35s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=16 rep=4 gateway=1.33s direct=1.42s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">async httpx.AsyncClient N=16 rep=5 gateway=1.77s direct=1.56s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Ten interleaved runs with the async callback, gateway times tracking the control on every row" src="https://development-wec.wiline.com/docs/assets/images/gw5-async-runs-44f396a0682d60c0748426e50abc69cf.png" width="508" height="233" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 6.</strong> The async client. Every gateway number sits beside its control, at both
concurrency levels. Nothing stands out because nothing happened.</p>
<p>Then swap the callback and run the same thing:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">waiting for the gateway up</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">callback in use: blocking httpx.Client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=8 rep=1 gateway=1.20s direct=0.93s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=8 rep=2 gateway=0.96s direct=0.83s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=8 rep=3 gateway=0.92s direct=0.86s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=8 rep=4 gateway=0.73s direct=0.90s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=8 rep=5 gateway=1.70s direct=0.90s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=16 rep=1 gateway=70.18s direct=2.00s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=16 rep=2 gateway=1.75s direct=1.70s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=16 rep=3 gateway=2.33s direct=1.78s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=16 rep=4 gateway=2.88s direct=1.49s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocking httpx.Client N=16 rep=5 gateway=5.14s direct=1.19s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Ten interleaved runs with the blocking callback, one batch at 70.18 seconds and another at 5.14 against controls near 1.2 to 2 seconds" src="https://development-wec.wiline.com/docs/assets/images/gw5-blocking-runs-df9535bdd0ed417cb65599e60ca5e742.png" width="512" height="239" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 7.</strong> The blocking client. Same script, same load, one word different in the
code. The control column holds at 1.19-2.00s throughout.</p>
<table><thead><tr><th>Version</th><th>N</th><th>Gateway median</th><th>Gateway worst</th><th>Control median</th></tr></thead><tbody><tr><td>async <code>AsyncClient</code></td><td>8</td><td>0.90s</td><td>0.98s</td><td>0.85s</td></tr><tr><td>async <code>AsyncClient</code></td><td>16</td><td>1.61s</td><td>1.77s</td><td>1.42s</td></tr><tr><td>blocking <code>Client</code></td><td>8</td><td>0.96s</td><td>1.70s</td><td>0.90s</td></tr><tr><td>blocking <code>Client</code></td><td>16</td><td>2.88s</td><td><strong>70.18s</strong> *</td><td>1.70s</td></tr></tbody></table>
<p><span class="zoomImage__wrap"><img alt="Log-scale dot plot of every batch in both runs. Grey control dots cluster between 0.7 and 2 seconds in all four groups. Green async dots sit among them. Two red blocking dots break away at 5.14 and 70.18 seconds" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA3NjAgNDQwIiB3aWR0aD0iNzYwIiBoZWlnaHQ9IjQ0MCIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLFNlZ29lIFVJLHNhbnMtc2VyaWYiPgo8dGl0bGU+V2FsbCB0aW1lIHBlciBiYXRjaCwgdGhyb3VnaCB0aGUgZ2F0ZXdheSB2ZXJzdXMgc3RyYWlnaHQgdG8gdGhlIHVwc3RyZWFtPC90aXRsZT4KPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9Ijc2MCIgaGVpZ2h0PSI0NDAiIGZpbGw9IiNmZmZmZmYiLz4KPGxpbmUgeDE9Ijg2IiB5MT0iMzUyLjAiIHgyPSI3MzAiIHkyPSIzNTIuMCIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utb3BhY2l0eT0iMC4zMCIvPgo8dGV4dCB4PSI3NCIgeT0iMzU2LjAiIHRleHQtYW5jaG9yPSJlbmQiIGZvbnQtc2l6ZT0iMTMiIGZpbGw9IiM2NDc0OGIiPjAuNXM8L3RleHQ+CjxsaW5lIHgxPSI4NiIgeTE9IjMxMS43IiB4Mj0iNzMwIiB5Mj0iMzExLjciIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLW9wYWNpdHk9IjAuMzAiLz4KPHRleHQgeD0iNzQiIHk9IjMxNS43IiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj4xczwvdGV4dD4KPGxpbmUgeDE9Ijg2IiB5MT0iMjcxLjQiIHgyPSI3MzAiIHkyPSIyNzEuNCIgc3Ryb2tlPSIjOTRhM2I4IiBzdHJva2Utb3BhY2l0eT0iMC4zMCIvPgo8dGV4dCB4PSI3NCIgeT0iMjc1LjQiIHRleHQtYW5jaG9yPSJlbmQiIGZvbnQtc2l6ZT0iMTMiIGZpbGw9IiM2NDc0OGIiPjJzPC90ZXh0Pgo8bGluZSB4MT0iODYiIHkxPSIyMTguMSIgeDI9IjczMCIgeTI9IjIxOC4xIiBzdHJva2U9IiM5NGEzYjgiIHN0cm9rZS1vcGFjaXR5PSIwLjMwIi8+Cjx0ZXh0IHg9Ijc0IiB5PSIyMjIuMSIgdGV4dC1hbmNob3I9ImVuZCIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+NXM8L3RleHQ+CjxsaW5lIHgxPSI4NiIgeTE9IjE3Ny45IiB4Mj0iNzMwIiB5Mj0iMTc3LjkiIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLW9wYWNpdHk9IjAuMzAiLz4KPHRleHQgeD0iNzQiIHk9IjE4MS45IiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj4xMHM8L3RleHQ+CjxsaW5lIHgxPSI4NiIgeTE9IjEzNy42IiB4Mj0iNzMwIiB5Mj0iMTM3LjYiIHN0cm9rZT0iIzk0YTNiOCIgc3Ryb2tlLW9wYWNpdHk9IjAuMzAiLz4KPHRleHQgeD0iNzQiIHk9IjE0MS42IiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj4yMHM8L3RleHQ+CjxsaW5lIHgxPSI4NiIgeTE9Ijg0LjMiIHgyPSI3MzAiIHkyPSI4NC4zIiBzdHJva2U9IiM5NGEzYjgiIHN0cm9rZS1vcGFjaXR5PSIwLjMwIi8+Cjx0ZXh0IHg9Ijc0IiB5PSI4OC4zIiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj41MHM8L3RleHQ+CjxsaW5lIHgxPSI4NiIgeTE9IjQ0LjAiIHgyPSI3MzAiIHkyPSI0NC4wIiBzdHJva2U9IiM5NGEzYjgiIHN0cm9rZS1vcGFjaXR5PSIwLjMwIi8+Cjx0ZXh0IHg9Ijc0IiB5PSI0OC4wIiB0ZXh0LWFuY2hvcj0iZW5kIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj4xMDBzPC90ZXh0Pgo8Y2lyY2xlIGN4PSIxNDAuNSIgY3k9IjMwNi43IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIxNDAuNSIgY3k9IjMyNC43IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIxNDAuNSIgY3k9IjMxNC4xIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIxNDAuNSIgY3k9IjMyMS4yIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIxNDAuNSIgY3k9IjMyMy4yIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIxOTIuNSIgY3k9IjMxMi45IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIxOTIuNSIgY3k9IjMyMC41IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIxOTIuNSIgY3k9IjMxNi42IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIxOTIuNSIgY3k9IjMyNi4xIiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIxOTIuNSIgY3k9IjMxNy44IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8dGV4dCB4PSIxNjYuNSIgeT0iMzc2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj5hc3luYzwvdGV4dD4KPHRleHQgeD0iMTY2LjUiIHk9IjM5MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+Tj04PC90ZXh0Pgo8Y2lyY2xlIGN4PSIzMDEuNSIgY3k9IjMxNS45IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIzMDEuNSIgY3k9IjMyMi41IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIzMDEuNSIgY3k9IjMyMC41IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIzMDEuNSIgY3k9IjMxNy44IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIzMDEuNSIgY3k9IjMxNy44IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSIzNTMuNSIgY3k9IjMwMS4xIiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIzNTMuNSIgY3k9IjMxNC4xIiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIzNTMuNSIgY3k9IjMxNi42IiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIzNTMuNSIgY3k9IjMzMC4wIiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSIzNTMuNSIgY3k9IjI4MC45IiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8dGV4dCB4PSIzMjcuNSIgeT0iMzc2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj5ibG9ja2luZzwvdGV4dD4KPHRleHQgeD0iMzI3LjUiIHk9IjM5MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+Tj04PC90ZXh0Pgo8Y2lyY2xlIGN4PSI0NjIuNSIgY3k9IjI4OC41IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSI0NjIuNSIgY3k9IjI5OC4zIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSI0NjIuNSIgY3k9IjI5NC4zIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSI0NjIuNSIgY3k9IjI5MS4zIiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSI0NjIuNSIgY3k9IjI4NS45IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiIGZpbGwtb3BhY2l0eT0iMC44NSIvPgo8Y2lyY2xlIGN4PSI1MTQuNSIgY3k9IjI4NC4wIiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI1MTQuNSIgY3k9IjI4Mi42IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI1MTQuNSIgY3k9IjI5Ny40IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI1MTQuNSIgY3k9IjI5NS4xIiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI1MTQuNSIgY3k9IjI3OC41IiByPSI1IiBmaWxsPSIjMDU5NjY5IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8dGV4dCB4PSI0ODguNSIgeT0iMzc2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj5hc3luYzwvdGV4dD4KPHRleHQgeD0iNDg4LjUiIHk9IjM5MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+Tj0xNjwvdGV4dD4KPGNpcmNsZSBjeD0iNjIzLjUiIGN5PSIyNzEuNCIgcj0iNC41IiBmaWxsPSIjOTRhM2I4IiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPGNpcmNsZSBjeD0iNjIzLjUiIGN5PSIyODAuOSIgcj0iNC41IiBmaWxsPSIjOTRhM2I4IiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPGNpcmNsZSBjeD0iNjIzLjUiIGN5PSIyNzguMiIgcj0iNC41IiBmaWxsPSIjOTRhM2I4IiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPGNpcmNsZSBjeD0iNjIzLjUiIGN5PSIyODguNSIgcj0iNC41IiBmaWxsPSIjOTRhM2I4IiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPGNpcmNsZSBjeD0iNjIzLjUiIGN5PSIzMDEuNiIgcj0iNC41IiBmaWxsPSIjOTRhM2I4IiBmaWxsLW9wYWNpdHk9IjAuODUiLz4KPGNpcmNsZSBjeD0iNjc1LjUiIGN5PSI2NC42IiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI2NzUuNSIgY3k9IjI3OS4yIiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI2NzUuNSIgY3k9IjI2Mi41IiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI2NzUuNSIgY3k9IjI1MC4yIiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8Y2lyY2xlIGN4PSI2NzUuNSIgY3k9IjIxNi41IiByPSI1IiBmaWxsPSIjZGMyNjI2IiBmaWxsLW9wYWNpdHk9IjAuOSIvPgo8dGV4dCB4PSI2NDkuNSIgeT0iMzc2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjEzIiBmaWxsPSIjNjQ3NDhiIj5ibG9ja2luZzwvdGV4dD4KPHRleHQgeD0iNjQ5LjUiIHk9IjM5MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+Tj0xNjwvdGV4dD4KPHRleHQgeD0iNjg3LjUiIHk9IjY4LjYiIGZvbnQtc2l6ZT0iMTMiIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiNkYzI2MjYiPjcwLjE4cyAqPC90ZXh0Pgo8dGV4dCB4PSI2ODcuNSIgeT0iMjIwLjUiIGZvbnQtc2l6ZT0iMTMiIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiNkYzI2MjYiPjUuMTRzPC90ZXh0Pgo8Y2lyY2xlIGN4PSI5NCIgY3k9IjI4IiByPSI0LjUiIGZpbGw9IiM5NGEzYjgiLz4KPHRleHQgeD0iMTA2IiB5PSIzMiIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+c3RyYWlnaHQgdG8gdGhlIHVwc3RyZWFtIChjb250cm9sKTwvdGV4dD4KPGNpcmNsZSBjeD0iMzg2IiBjeT0iMjgiIHI9IjUiIGZpbGw9IiMwNTk2NjkiLz4KPHRleHQgeD0iMzk4IiB5PSIzMiIgZm9udC1zaXplPSIxMyIgZmlsbD0iIzY0NzQ4YiI+Z2F0ZXdheSwgYXN5bmM8L3RleHQ+CjxjaXJjbGUgY3g9IjUzNiIgY3k9IjI4IiByPSI1IiBmaWxsPSIjZGMyNjI2Ii8+Cjx0ZXh0IHg9IjU0OCIgeT0iMzIiIGZvbnQtc2l6ZT0iMTMiIGZpbGw9IiM2NDc0OGIiPmdhdGV3YXksIGJsb2NraW5nPC90ZXh0Pgo8dGV4dCB4PSI4NiIgeT0iNDI4IiBmb250LXNpemU9IjEyIiBmaWxsPSIjNjQ3NDhiIj4qIHRoaXMgYmF0Y2ggb3ZlcmxhcHBlZCBhbiB1bnJlbGF0ZWQgbG9hZCB0ZXN0IG9uIHRoZSBzYW1lIGhvc3Qg4oCUIHRyZWF0IGl0IGFzIGFuIHVwcGVyIGJvdW5kPC90ZXh0Pgo8L3N2Zz4=" width="760" height="440" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 8.</strong> Every batch from Figures 6 and 7 on a log axis. The control stays inside a
narrow band in all four groups. The async runs stay with it. Two blocking runs leave it.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>One number in that table needs an asterisk</div><div class="admonitionContent_BuS1"><p>The 70.18s batch overlapped an unrelated load test running against the same gateway, so
part of it is not attributable to the callback. It is reported because it happened, not
because it is clean. Read the 5.14s batch as the representative stall — still more than
four times its own control of 1.19s.</p><p>The effect itself reproduced across four separate runs on this host, with N=16 stalls of
11.22s, 12.44s, 21.22s, 31.14s and 5.14s. Across every clean async run: none.</p></div></div>
<p>Read the async rows first. At N=8 the gateway's median is 0.90s and the control's is
0.85s. The callback, Presidio round trips and all, disappears into the model's own
latency.</p>
<p>Now the blocking row at N=8. Median 0.96s against a 0.90s control. That is 0.06s. If you
measured medians and shipped, you would call it fine — and the worst batch in the same
five took 1.70s while its own control took 0.90s.</p>
<p>At N=16 it stops hiding. The median goes to 2.88s against 1.70s, and the tail runs off
the chart.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can tell whether a slow gateway is your own code or the model it calls — run the same
load against both, interleaved, and read the tail instead of the median.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="it-does-not-just-get-slow-it-drops-requests">It does not just get slow. It drops requests.<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#it-does-not-just-get-slow-it-drops-requests" class="hash-link" aria-label="Direct link to It does not just get slow. It drops requests." title="Direct link to It does not just get slow. It drops requests." translate="no">​</a></h2>
<p>Before failure counting was added to the load script, a blocking run died outright:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">httpx.RemoteProtocolError: Server disconnected without sending a response.</span><br></div></code></pre></div></div>
<p>Two of ten batches in that run lost requests that way. Zero async batches ever did. The
gateway was not slow for those callers. It hung up on them.</p>
<p>Ask the gateway what it thinks happened:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs llm-gateway </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"httpcore/_sync"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs llm-gateway </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-A1</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"httpx.ReadTimeout"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">tail</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-4</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">77</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     172.22.0.1:47566 - "POST /v1/chat/completions HTTP/1.1" 200 OK</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">--</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">httpx.ReadTimeout: timed out</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     172.22.0.1:52870 - "POST /v1/chat/completions HTTP/1.1" 200 OK</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Seventy-seven sync stack frames in the log, and a ReadTimeout sitting between two successful 200 OK responses" src="https://development-wec.wiline.com/docs/assets/images/gw5-sync-timeout-d454e62c9e5512f71923e0ec2c0efbec.png" width="645" height="142" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 9.</strong> <code>httpcore/_sync</code> appears 77 times. The async client would produce
<code>_async</code>. And the timeout sits between two <code>200 OK</code> lines.</p>
<p>That <code>_sync</code> is the fingerprint. It proves the failing call is the blocking Presidio call
and not something else in the stack. The full error names the caller:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">LiteLLM:ERROR: logging_worker.py:103 - LoggingWorker error: timed out</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  File ".../httpcore/_sync/connection_pool.py", line 236, in handle_request</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  httpcore.ReadTimeout: timed out</span><br></div></code></pre></div></div>
<p><strong>Now look at what surrounds it.</strong> <code>200 OK</code>. The requests succeeded. The masking is what
failed.</p>
<p>That is the part worth stopping on, because Part 4's whole promise was that PII reaches
your traces already masked. Under load, with the blocking client, the masking call times
out against its own ten-second limit while the caller receives a normal success. Nothing
in the response tells you the guarantee lapsed. You would have to be reading the
gateway's stderr to know.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-is-actually-happening">What is actually happening<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#what-is-actually-happening" class="hash-link" aria-label="Direct link to What is actually happening" title="Direct link to What is actually happening" translate="no">​</a></h2>
<p><code>httpx.Client</code> inside an <code>async def</code> does not yield. When the callback calls Presidio, the
thread running the event loop sits in a socket read until Presidio answers, and during
that time the loop services nothing — not another request's model call, not a response
coming back. It is one loop, as you confirmed at the start.</p>
<p>Each request triggers several of these calls: the messages, the copy in the standard
logging object, and the response. So under concurrency the requests do not overlap. They
queue, and each one's wait is every earlier one's Presidio time added together. That is
why the effect is not a constant tax — it depends on how many requests happen to arrive
while the loop is held, which is also why the numbers are spiky rather than uniformly
worse.</p>
<p>Past a certain queue depth the waits exceed the scrubber's own <code>timeout=10.0</code> and the
masking gives up, while client connections waiting on a frozen loop get dropped. The
median hides all of it because most batches get lucky. The tail is the truth.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="fixing-it">Fixing it<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#fixing-it" class="hash-link" aria-label="Direct link to Fixing it" title="Direct link to Fixing it" translate="no">​</a></h2>
<p>The fix is the one Part 4 landed on, and this is the evidence for it: <code>httpx.AsyncClient</code>
with <code>await</code>, which yields the loop while Presidio works.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">diff</span><span class="token plain"> scrubber.py scrubber_blocking.py</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">26,27c26,27</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">&lt;     async with httpx.AsyncClient(timeout=10.0) as client:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">&lt;         r = await client.post(f"{ANALYZER}/analyze", json={"text": text, "language": "en"})</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">---</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">&gt;     with httpx.Client(timeout=10.0) as client:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">&gt;         r = client.post(f"{ANALYZER}/analyze", json={"text": text, "language": "en"})</span><br></div></code></pre></div></div>
<p>Two words and an <code>await</code>. That is the whole difference between Figure 6 and Figure 7.</p>
<p>More workers is not the fix, and the documentation explains why better than we could. On
<code>--timeout_worker_healthcheck</code>, describing <code>--num_workers &gt; 1</code>:</p>
<blockquote>
<p><em>"the supervisor process pings each worker; a worker that does not respond within this
window (for example because its event loop is blocked by synchronous work) is killed
with SIGKILL and replaced."</em></p>
</blockquote>
<p>At one worker there is no supervisor, so a blocked loop stalls. At several, a blocked
worker gets killed and everything in flight on it dies instead of waiting. Neither is a
fix for synchronous work on an event loop. Fix the call.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Prove it holds under load</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-did-not-test">What we did not test<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#what-we-did-not-test" class="hash-link" aria-label="Direct link to What we did not test" title="Direct link to What we did not test" translate="no">​</a></h2>
<p>Five reps per cell is enough to show that a stall exists next to a steady control. It is
not enough to characterise the distribution — how often, how bad, or how it scales past
N=16. If you run this in production, run it longer and look at percentiles.</p>
<p>We did not test <code>--num_workers &gt; 1</code>, streaming responses, or a slow or remote Presidio
rather than a healthy local container.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-timeout-actually-costs-you">What the timeout actually costs you<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#what-the-timeout-actually-costs-you" class="hash-link" aria-label="Direct link to What the timeout actually costs you" title="Direct link to What the timeout actually costs you" translate="no">​</a></h2>
<p>The obvious worry, when the masking call times out on a request that returned <code>200 OK</code>,
is that the unmasked payload lands in the trace anyway. It does not.</p>
<p>Sustained load — six rounds of twenty-four concurrent requests, each carrying a name and
a phone number — produced <strong>77 masking timeouts</strong> in the gateway log. Of the 144
requests, <strong>62 traces reached Langfuse and 82 never arrived at all.</strong> Every one of the 62
was properly masked. None carried a raw name.</p>
<p>So the failure is not a privacy failure. It is an observability failure, and a quiet one:
under sustained load more than half the traffic simply is not recorded, while every
caller gets a normal success. If you are reading trace volume as a proxy for traffic, or
counting on traces for an audit trail, that gap is the thing to watch — and it is
invisible from the response side.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Measure detection before you measure anything else</div><div class="admonitionContent_BuS1"><p>Building this test, requests were tagged with a short marker so each trace could be found
— <code>[TAG-07] Priya Raghunathan called from …</code>. Nine traces then came back with the phone
masked and the name in the clear, which reads exactly like a load-induced leak.</p><p>It was not. Sequentially, with no load at all, that sentence sent straight to Presidio
returns only <code>PHONE_NUMBER</code>; the same sentence without the bracketed prefix returns
<code>PERSON</code> and <code>PHONE_NUMBER</code> both. The marker suppressed name detection, and the callback
faithfully masked everything Presidio reported.</p><p>Check what your analyzer detects in your exact strings before concluding anything about
masking under load. An instrument that changes the thing it measures will hand you a
finding that is not there.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/">Part 4 — clean traces, untouched answers</a> — the callback measured here</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/">Part 3 — mask PII at the gateway</a> — the Presidio setup</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">Observe production with Langfuse</a> — where these traces land</li>
</ul>]]></content:encoded>
            <category>llm</category>
            <category>gateway</category>
            <category>litellm</category>
            <category>performance</category>
            <category>concurrency</category>
            <category>presidio</category>
            <category>observability</category>
            <category>wec</category>
            <category>wec-inference</category>
        </item>
        <item>
            <title><![CDATA[Hand work between LangGraph agents without corrupting shared state]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/</guid>
            <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A supervisor routes work to specialists, an agent hands off mid-task, and two agents write the same state key in the same step. One of those raises an error rather than picking a winner — and the routing decision costs 412 output tokens to say one word.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg xmlns="http://www.w3.org/2000/svg" width="487" height="249" fill="none" viewBox="0 0 487 249" class="tutorialHero__brand"><path fill="#1C3C3C" fill-rule="evenodd" d="M124.273.412h239.031c68.111 0 123.52 55.603 123.52 123.948s-55.409 123.948-123.52 123.948H124.273c-68.11 0-123.52-55.603-123.52-123.948S56.164.412 124.274.412M234.018 192.55c3.001 3.156 7.443 3 11.378 2.182l.039.02c1.827-1.486-.77-3.368-3.249-5.165-1.487-1.077-2.932-2.125-3.356-3.038 1.373-1.674-2.687-5.474-5.847-8.433-1.327-1.242-2.495-2.335-3.037-3.061-2.248-2.448-3.152-5.537-4.062-8.644-.603-2.061-1.208-4.131-2.211-6.027-6.176-14.339-13.248-28.561-23.165-40.699-6.373-8.067-13.646-15.291-20.922-22.518-4.691-4.66-9.382-9.319-13.835-14.206-4.581-4.73-7.338-10.556-10.1-16.392-2.312-4.886-4.627-9.779-8.018-14.04-10.268-15.196-42.686-19.346-47.44 2.124.019.662-.195 1.09-.78 1.52-2.63 1.928-4.968 4.11-6.935 6.76-4.812 6.721-5.553 18.118.448 24.158l.025-.388c.2-3.05.388-5.9 2.8-8.087 4.637 3.994 11.67 5.416 17.047 2.435 6.483 9.3 8.548 20.543 10.621 31.823 1.726 9.398 3.457 18.821 7.751 27.171l.266.443c2.524 4.202 5.089 8.472 8.326 12.142 1.176 1.823 3.59 3.791 6 5.755 3.18 2.591 6.353 5.177 6.663 7.416.015.974.01 1.961.006 2.954-.025 5.879-.051 11.967 3.716 16.801 2.084 4.228-3.02 8.475-7.131 7.949-2.254.312-4.716-.282-7.161-.871-3.346-.807-6.659-1.607-9.36-.064-.758.82-1.846.849-2.94.877-1.297.035-2.601.069-3.373 1.422-.158.402-.528.856-.913 1.328-.846 1.036-1.762 2.159-.665 3.016q.149-.111.294-.224c1.663-1.269 3.248-2.479 5.493-1.724-.299 1.658.772 2.103 1.843 2.547q.281.114.553.239c-.011.385-.087.774-.163 1.159-.18.921-.356 1.824.358 2.62.339-.344.639-.732.939-1.12.735-.95 1.474-1.904 2.802-2.25 2.92 3.9 5.862 2.28 9.554.248 4.164-2.293 9.281-5.111 16.396-1.125-2.727-.136-5.163.195-6.994 2.455-.448.507-.838 1.091-.039 1.754 4.21-2.728 5.961-1.748 7.61-.825 1.19.666 2.326 1.302 4.294.493.465-.242.93-.493 1.396-.745 3.161-1.705 6.366-3.434 10.118-2.839-2.803.808-3.8 2.584-4.888 4.524-.539.959-1.099 1.957-1.911 2.898-.429.429-.624.936-.137 1.656 5.869-.488 8.087-1.976 11.083-3.986 1.429-.959 3.036-2.038 5.302-3.183 2.505-1.542 5.009-.556 7.436.4 2.633 1.037 5.175 2.037 7.527-.264.743-.7 1.674-.708 2.602-.717a10 10 0 0 0 1.002-.043c-.732-3.92-4.861-3.874-9.052-3.827-4.847.054-9.778.109-9.632-5.972 4.504-3.076 4.546-8.415 4.585-13.461.01-1.218.019-2.419.091-3.567 3.313 1.847 6.817 3.291 10.299 4.725 3.276 1.35 6.533 2.692 9.593 4.354 3.195 5.143 8.182 11.962 14.826 11.514.175-.526.331-.974.526-1.5.383.067.787.169 1.199.273 1.743.442 3.611.915 4.509-1.15m130.213-58.45a20.54 20.54 0 0 0 14.504 5.994c5.44 0 10.658-2.156 14.505-5.994a20.44 20.44 0 0 0 6.007-14.469 20.44 20.44 0 0 0-6.007-14.469 20.54 20.54 0 0 0-21.882-4.624l-11.757-17.162-8.194 5.614 11.818 17.25a20.43 20.43 0 0 0-5.002 13.391 20.43 20.43 0 0 0 6.008 14.469m-36.808-55.576a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.784 20.47 20.47 0 0 0-7.873-10.09 20.557 20.557 0 0 0-18.35-2.282 20.5 20.5 0 0 0-7.964 5.175 20.44 20.44 0 0 0-4.77 8.201 20.429 20.429 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m0 118.824a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.783 20.47 20.47 0 0 0-7.873-10.092 20.55 20.55 0 0 0-26.314 2.894 20.45 20.45 0 0 0-4.77 8.201 20.43 20.43 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m18.857-72.629v-10.174h-31.394a20.3 20.3 0 0 0-4.398-8.342L322.3 88.704l-8.59-5.698-11.812 17.499a20.5 20.5 0 0 0-6.749-1.221 20.5 20.5 0 0 0-14.462 5.959 20.3 20.3 0 0 0-5.99 14.388 20.3 20.3 0 0 0 5.99 14.387 20.5 20.5 0 0 0 14.462 5.96 20.5 20.5 0 0 0 6.749-1.221l11.812 17.498 8.487-5.697-11.709-17.498a20.3 20.3 0 0 0 4.398-8.342z" clip-rule="evenodd"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/Postgresql_elephant.svg-62fedb3464ae364ae4e342c86b474ed6.png" alt="PostgreSQL"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 4 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->4</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->4<!-- --> earned</span></div><div class="skillTracker__series">Agent orchestration with LangGraph</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">State that survives a restart</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">2</span><span class="skillTracker__skill" data-state="current">Hand work between agents</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Governed tools an agent can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Engineer the context, not the prompt</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>You have two agents. One books appointments. One handles billing.</p>
<p>A ticket arrives that needs both: <em>reschedule my install, and my bill looks wrong.</em>
So you send it to both.</p>
<p>The first one books Tuesday. The second one sees the account is past due and
freezes it. Both finish at almost the same moment, and both save what they decided.</p>
<p>Only one of them is saved. Which one? Whichever finished first — which comes down to
how slow an API call was that day. So you book an appointment on a frozen account,
or freeze an account you just promised an engineer to. Afterwards it looks like one
clean decision was made.</p>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/">Part 1</a> built one agent that runs
its steps in a fixed order. This post has several: a supervisor that picks who works
on what, an agent that passes the job to another one halfway through, and two agents
put in each other's way on purpose — to find out what LangGraph does when they
disagree.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Verified versions: Python 3.10.12 · <code>langgraph</code> 1.2.11 ·
<code>langgraph-checkpoint</code> 4.2.0 · <code>langgraph-checkpoint-postgres</code> 3.1.2 ·
<code>psycopg</code> 3.3.4 · <code>langchain-openai</code> 1.6.0 · <code>langfuse</code> 4.14.5 against Langfuse
server v3.205.1 OSS · image <code>postgres:17</code>. The <code>lg-checkpoints</code> container and
virtualenv from Part 1 are reused unchanged.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A WEC Instance — <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/">deploy one</a> if you have not already</li>
<li class="">A WEC Inference API key — <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">create one</a> in the portal</li>
<li class="">The Postgres container and virtualenv from <a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/">Part 1</a></li>
<li class="">A Langfuse instance for the last section — see <a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">Observe production with Langfuse</a></li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="someone-has-to-decide-who-works-on-it">Someone has to decide who works on it<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#someone-has-to-decide-who-works-on-it" class="hash-link" aria-label="Direct link to Someone has to decide who works on it" title="Direct link to Someone has to decide who works on it" translate="no">​</a></h2>
<p>Two agents means something has to choose between them. That thing is called a
supervisor, and it is just another agent whose only job is picking the next one.</p>
<p>Part 1 wired its steps together in advance with <code>add_edge</code> — step one, then two,
then three. A supervisor cannot do that, because it does not know where the work
is going until it reads it.</p>
<p>Instead the node returns a <code>Command</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Command</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">supervisor</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    nxt </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"invoice"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"ticket"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor routing to </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">nxt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor-&gt;</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>Command</code> does two jobs at once: <code>goto</code> names the next node, and <code>update</code> carries
a state change with it. There is <strong>no <code>add_edge</code> from <code>supervisor</code> to anything</strong> —
routing happens at runtime, from inside the function.</p>
<p>Save the whole thing as <code>lg_super.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_super.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> sys</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> operator </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> add</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Literal</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing_extensions </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> TypedDict</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">graph </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">checkpoint</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">postgres </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> PostgresSaver</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Command</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DB_URI </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"postgresql://langgraph:changeme@127.0.0.1:5434/checkpoints"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">THREAD </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"sup-1"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">TICKET </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"reschedule my install"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    ticket</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">supervisor</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    nxt </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"invoice"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"ticket"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor routing to </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">nxt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor-&gt;</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">scheduler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">billing</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"billing running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"supervisor"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> supervisor</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> scheduler</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> billing</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"supervisor"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"--draw"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get_graph</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">draw_mermaid</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">raise</span><span class="token plain"> SystemExit</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> PostgresSaver</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">from_conn_string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB_URI</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> cp</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    cp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">setup</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    graph </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">cp</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"FINAL:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"ticket"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> TICKET</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>--draw</code> branch prints the graph and exits before touching Postgres, so you can
inspect the topology without a database. It earns its place in the next section.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_super.py sup-1 </span><span class="token string" style="color:#e3116c">"reschedule my install"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">supervisor routing to scheduler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'ticket': 'reschedule my install', 'completed': ['supervisor-&gt;scheduler', 'scheduler']}</span><br></div></code></pre></div></div>
<p>And the other branch, to show the routing is data-dependent rather than hardcoded:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_super.py sup-2 </span><span class="token string" style="color:#e3116c">"please fix the invoice on my account"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">supervisor routing to billing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'ticket': 'please fix the invoice on my account', 'completed': ['supervisor-&gt;billing', 'billing']}</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-annotation-that-is-not-type-hinting">The annotation that is not type hinting<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#the-annotation-that-is-not-type-hinting" class="hash-link" aria-label="Direct link to The annotation that is not type hinting" title="Direct link to The annotation that is not type hinting" translate="no">​</a></h3>
<p>That return annotation looks like ordinary typing you could drop:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">supervisor</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><br></div></code></pre></div></div>
<p>Drop it and the code runs identically — same routing, same output. So it is
optional. Except it is not.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/ -&gt; Command\[Literal\["scheduler", "billing"\]\]//'</span><span class="token plain"> ~/lg_super.py </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> ~/lg_super_noann.py</span><br></div></code></pre></div></div>
<p>Ask both versions to draw themselves:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_super.py </span><span class="token parameter variable" style="color:#36acaa">--draw</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_super_noann.py </span><span class="token parameter variable" style="color:#36acaa">--draw</span><br></div></code></pre></div></div>
<p>With the annotation, the two possible routes are there as dotted conditional
edges:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (with annotation)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">__start__ --&gt; supervisor;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">supervisor -.-&gt; billing;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">supervisor -.-&gt; scheduler;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing --&gt; __end__;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler --&gt; __end__;</span><br></div></code></pre></div></div>
<p>Without it, they vanish, and the graph states something false:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (without annotation)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">__start__ --&gt; supervisor;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">supervisor --&gt; __end__;</span><br></div></code></pre></div></div>
<p><code>scheduler</code> and <code>billing</code> are still declared as nodes, and now nothing reaches
them. The diagram says the supervisor goes straight to the end.</p>
<p><span class="zoomImage__wrap"><img alt="Two mermaid graphs side by side, one with dotted conditional edges to both agents and one where the supervisor connects straight to end" src="https://development-wec.wiline.com/docs/assets/images/lg2-graphs-522fc91da9a7554bb7444d68736b3ee7.png" width="585" height="681" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> Same code, one annotation apart. The lower graph is wrong.</p>
<p>The reason is mechanical: <code>goto</code> is decided inside the function body, where
nothing can inspect it. The annotation is the <strong>only</strong> declaration of where a
supervisor is allowed to send work. Omit it and your code still works while every
diagram, and anything else reading the topology, quietly lies.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>draw_ascii()</code> needs a package LangGraph does not ship</div><div class="admonitionContent_BuS1"><p><code>get_graph().draw_ascii()</code> raises
<code>ImportError: Install grandalf to draw graphs: 'pip install grandalf'</code>.
<code>draw_mermaid()</code> has no extra dependency and is what the examples above use.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="handing-off-without-asking-the-supervisor">Handing off without asking the supervisor<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#handing-off-without-asking-the-supervisor" class="hash-link" aria-label="Direct link to Handing off without asking the supervisor" title="Direct link to Handing off without asking the supervisor" translate="no">​</a></h2>
<p>A supervisor decides once, at the start. That is a problem when the discovery
happens later.</p>
<p>Take a ticket asking to reschedule an install, on an account that turns out to be
past due. The supervisor sees a scheduling request and routes accordingly. The
scheduler starts work, reads the account, and finds a problem billing owns.</p>
<p>The scheduler can route too — it is a node, and nodes return <code>Command</code>.</p>
<p>Copy <code>lg_super.py</code> to <code>lg_handoff.py</code>, replace <code>scheduler</code> with the version below,
and delete the <code>builder.add_edge("scheduler", END)</code> line — the node decides its own
exit now:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_handoff.py (changed parts)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">scheduler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__end__"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"past due"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"ticket"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler: account is past due, handing off to billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler(handoff)"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"scheduler: booking it"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">END</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler(booked)"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_handoff.py handoff-3 </span><span class="token string" style="color:#e3116c">"reschedule my install, account is past due"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_handoff.py handoff-4 </span><span class="token string" style="color:#e3116c">"reschedule my install"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">supervisor -&gt; scheduler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler: account is past due, handing off to billing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'ticket': '...past due', 'completed': ['supervisor', 'scheduler(handoff)', 'billing']}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">supervisor -&gt; scheduler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler: booking it</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'ticket': 'reschedule my install', 'completed': ['supervisor', 'scheduler(booked)']}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Both handoff branches — one routing on to billing, one ending at the scheduler" src="https://development-wec.wiline.com/docs/assets/images/lg2-handoff-4bb2edcb710dc048a8d846ee980848c2.png" width="912" height="193" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The supervisor never knew billing would be involved.</p>
<p>Two details worth keeping. <code>goto=END</code> works from inside a <code>Command</code>, so a node can
finish the run itself. And <code>"__end__"</code> is the string form of <code>END</code> in that
<code>Literal</code> — the annotation needs the literal value, not the constant.</p>
<p>This is the difference between the two published patterns. A <strong>supervisor</strong> decides
from the outside and needs to know every precondition up front. A <strong>handoff</strong> lets
the agent doing the work redirect once it learns something. Most real systems want
both, which is what this graph has.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="sending-work-to-two-agents-at-once">Sending work to two agents at once<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#sending-work-to-two-agents-at-once" class="hash-link" aria-label="Direct link to Sending work to two agents at once" title="Direct link to Sending work to two agents at once" translate="no">​</a></h2>
<p>So far the supervisor picks one agent. Sometimes you want both — check the calendar
and the account at the same time, rather than waiting for one before starting the
other. <code>goto</code> takes a list for exactly that:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"supervisor"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Both run in the same super-step. Which is where it gets interesting, because now
they can disagree.</p>
<p>Copy <code>lg_super.py</code> to <code>lg_conflict.py</code>. Give the state two kinds of field:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_conflict.py (changed parts)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># has a reducer</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    decision</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain">                          </span><span class="token comment" style="color:#999988;font-style:italic"># no reducer</span><br></div></code></pre></div></div>
<p>And have each agent write a different value to the un-reduced one:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">scheduler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"decision"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"book tuesday"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">billing</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"decision"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"refund first"</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_conflict.py conflict-2</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">supervisor sending work to BOTH</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing deciding</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler deciding</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Traceback (most recent call last):</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  File ".../langgraph/pregel/_loop.py", line 692, in after_tick</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    self.updated_channels = apply_writes(</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  File ".../langgraph/channels/last_value.py", line 64, in update</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    raise InvalidUpdateError(msg)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langgraph.errors.InvalidUpdateError: At key 'decision': Can receive only one value</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">per step. Use an Annotated key to handle multiple values.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The InvalidUpdateError traceback ending at last_value.py with the &amp;#39;At key decision&amp;#39; message" src="https://development-wec.wiline.com/docs/assets/images/lg2-conflict-906aa9ed90d5c25e9021f93fef593bcf.png" width="993" height="328" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> Both agents finished. The graph refused to merge their answers.</p>
<p>Read the trace carefully, because three things in it matter.</p>
<p><strong>Both agents ran.</strong> <code>billing deciding</code> and <code>scheduler deciding</code> both printed. The
work completed; the failure came afterwards, in <code>after_tick</code> → <code>apply_writes</code>. This
is not two agents colliding mid-flight. It is the graph refusing to reconcile them
at the step boundary.</p>
<p><strong><code>completed</code> was fine.</strong> Both agents appended to it in the same step and nothing
complained, because <code>add</code> says what two values mean. Only <code>decision</code> failed.</p>
<p><strong>LangGraph does not pick a winner.</strong> No last-write-wins, no silent choice. Which
is the right behaviour — the alternative is a booking agent that sometimes books
Tuesday and sometimes issues a refund depending on which task happened to finish
first.</p>
<p>Note the order too: <code>billing</code> printed before <code>scheduler</code>, the reverse of the <code>goto</code>
list. Parallel branches finish in whatever order they finish.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="stating-the-policy">Stating the policy<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#stating-the-policy" class="hash-link" aria-label="Direct link to Stating the policy" title="Direct link to Stating the policy" translate="no">​</a></h3>
<p>The fix is not to prevent the disagreement. It is to say what a disagreement
means. Copy <code>lg_conflict.py</code> to <code>lg_resolve.py</code> and add the reducer:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_resolve.py (changed parts)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">PRIORITY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"refund first"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"book tuesday"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">prefer_higher_priority</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">old</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> new</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token triple-quoted-string string" style="color:#e3116c">"""The policy: a billing hold outranks a scheduling decision."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    winner </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">max</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">old</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> new</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> key</span><span class="token operator" style="color:#393A34">=</span><span class="token keyword" style="color:#00009f">lambda</span><span class="token plain"> v</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> PRIORITY</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">v</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"  reducer: '</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">old</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">' vs '</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">new</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">' -&gt; '</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">winner</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">'"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> winner</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    decision</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> prefer_higher_priority</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_resolve.py resolve-2</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  reducer: '' vs '' -&gt; ''</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">supervisor sending work to BOTH</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing deciding</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scheduler deciding</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  reducer: '' vs 'refund first' -&gt; 'refund first'</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  reducer: 'refund first' vs 'book tuesday' -&gt; 'refund first'</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'completed': ['supervisor', 'billing', 'scheduler'], 'decision': 'refund first'}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The reducer firing three times, folding two agent decisions into one winner" src="https://development-wec.wiline.com/docs/assets/images/lg2-resolve-e9d09a07776b7e33a95a0f951453e628.png" width="624" height="158" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The print inside the reducer makes the merge visible.</p>
<p>Same graph, same disagreement, no error. And the trace shows three things the
documentation does not spell out:</p>
<p><strong>The reducer runs pairwise, folded left.</strong> Not once with both values — twice,
accumulating. First it merges the existing state with billing's answer, then merges
that result with scheduler's.</p>
<p><strong>It must therefore be order-independent.</strong> Since branches finish in arbitrary
order, a reducer like "take the newest" produces different answers on different
runs. <code>max</code> by priority does not.</p>
<p><strong>It fires before anything runs.</strong> That first <code>reducer: '' vs '' -&gt; ''</code> is the
initial state being written, merging the default with the <code>decision: ""</code> passed to
<code>invoke</code>. Your reducer sees empty input and must tolerate it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="crashing-halfway-through-a-parallel-step">Crashing halfway through a parallel step<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#crashing-halfway-through-a-parallel-step" class="hash-link" aria-label="Direct link to Crashing halfway through a parallel step" title="Direct link to Crashing halfway through a parallel step" translate="no">​</a></h2>
<p>Part 1 showed a killed run resuming from its last completed node. Parallel work
raises a sharper question: if one branch finishes and its sibling dies, is the
finished work lost?</p>
<p>Copy <code>lg_conflict.py</code> to <code>lg_partial.py</code>. Drop the <code>decision</code> field, add
<code>RESUME = "--resume" in sys.argv</code>, pass <code>None if RESUME else {...}</code> to <code>invoke</code> as
in Part 1, and replace the two specialists with one instant node and one slow
enough to kill:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_partial.py (changed parts)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">fast</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"fast running — finishes immediately"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"fast"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">slow</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"slow running — 30s window, KILL ME HERE"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">30</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"slow"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_partial.py partial-2</span><br></div></code></pre></div></div>
<p>Press Ctrl+C during the window. It takes two presses, and the run ends on
something that looks much worse than a <code>KeyboardInterrupt</code>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (tail)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  File ".../langgraph/pregel/_runner.py", line 613, in commit</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    self.put_writes()(task.id, task.writes)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RuntimeError: cannot schedule new futures after shutdown</span><br></div></code></pre></div></div>
<p>That is <code>fast</code>'s checkpoint write being committed to an executor that has already
shut down. It reads like data loss.</p>
<p>It is not:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">~/.venv/bin/python ~/lg_partial.py partial-2 </span><span class="token parameter variable" style="color:#36acaa">--resume</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">BEFORE  values={'completed': ['supervisor', 'fast']}  next=('slow',)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">slow running — 30s window, KILL ME HERE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'completed': ['supervisor', 'fast', 'slow']}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The resumed run showing fast recovered from Postgres and only slow pending" src="https://development-wec.wiline.com/docs/assets/images/lg2-partial-13329c0354dca69472571333fd73fb02.png" width="657" height="100" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> <code>fast</code> recovered, did not re-run, and <code>next</code> names only the branch that died.</p>
<p><code>fast</code> is in the recovered state, appears exactly once in the final result, and
<code>next=('slow',)</code> names only the branch that never finished. The write had already
landed in <code>checkpoint_writes</code> — the same table that stopped <code>step_one</code> re-running
in Part 1 now protects a completed sibling.</p>
<p>Worth documenting precisely because the error suggests the opposite. A
<code>RuntimeError</code> in a shutdown path is noise; the state is the thing to check.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-model-as-the-router">A model as the router<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#a-model-as-the-router" class="hash-link" aria-label="Direct link to A model as the router" title="Direct link to A model as the router" translate="no">​</a></h2>
<p>Every routing decision so far has been <code>"invoice" in ticket</code> — keyword matching.
That is the approach we measured coming apart on
<a class="" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/">the gateway's complexity router</a>, where a
plural is enough to miss a keyword entirely.</p>
<p>So let the model decide. Copy <code>lg_super.py</code> to <code>lg_traced.py</code> — same graph, one
node changed:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_traced.py (changed parts)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">llm </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"gemma4"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">supervisor</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">Literal</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> llm</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"Route this support ticket to exactly one team. "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"Answer with one word, either scheduler or billing, nothing else.\n\n"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string-interpolation string" style="color:#e3116c">f"Ticket: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">state</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'ticket'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    raw </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    nxt </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> raw </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"scheduler"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor: model said </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">raw</span><span class="token string-interpolation interpolation conversion-option punctuation" style="color:#393A34">!r</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> -&gt; </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">goto</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">nxt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> update</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string-interpolation string" style="color:#e3116c">f"supervisor-&gt;</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">nxt</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Credentials come from the environment, which the gateway's <code>.env</code> already holds:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">set</span><span class="token plain"> -a</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> ~/llm-gateway/.env</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> ~/.venv/bin/python ~/lg_traced.py traced-1</span><br></div></code></pre></div></div>
<p>The ticket is deliberately ambiguous — <em>"my bill looks wrong and I need a new
install date"</em> mentions both:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">supervisor: model said 'billing' -&gt; billing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'ticket': 'my bill looks wrong and I need a new install date', 'completed': ['supervisor-&gt;billing', 'billing']}</span><br></div></code></pre></div></div>
<p>One word, no wrapper sentence, and the parse held. It picked billing — defensible,
since an unpaid account gates the install, which is the same conclusion the handoff
reached earlier by reading the account.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-that-one-word-cost">What that one word cost<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#what-that-one-word-cost" class="hash-link" aria-label="Direct link to What that one word cost" title="Direct link to What that one word cost" translate="no">​</a></h3>
<p>Asking a model to choose is not free, and the bill is not where you would look for
it. Turning on tracing shows where the time and the tokens went. It is one line, as
in Part 1 — <code>CallbackHandler()</code> reads its credentials from the environment and goes
in the same config dict as the thread id:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">langchain </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> CallbackHandler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">handler </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> CallbackHandler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"callbacks"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">handler</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Langfuse trace showing supervisor and billing spans, the token badge, the serialised Command with goto, and billing receiving the supervisor&amp;#39;s state update" src="https://development-wec.wiline.com/docs/assets/images/lg2-trace-771785326bcdaf3d0132233cc7bdf9fc.png" width="1903" height="998" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> Three things boxed: what routing cost, the decision as data, and the payload arriving.</p>
<p>The span tree is the graph:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">LangGraph        8.66s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  supervisor     8.53s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    ChatOpenAI   8.49s   52 → 412 (Σ 464)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  billing        0.01s</span><br></div></code></pre></div></div>
<p>Three findings sit in that frame.</p>
<p><strong>A one-word answer cost 412 output tokens.</strong> Confirmed against the API rather
than read off the screen:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">set</span><span class="token plain"> -a</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> ~/llm-gateway/.env</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$LANGFUSE_PUBLIC_KEY</span><span class="token string" style="color:#e3116c">:</span><span class="token string variable" style="color:#36acaa">$LANGFUSE_SECRET_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$LANGFUSE_HOST</span><span class="token string" style="color:#e3116c">/api/public/observations?limit=8"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.data[] | select(.name=="ChatOpenAI") | {model, usage}'</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"model"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"gemma4"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"usage"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"unit"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"TOKENS"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"input"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">52</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"output"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">412</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"total"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">464</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>The visible answer is seven characters. The bill is 412 output tokens. Whatever the
model generated before settling, you paid for — and a routing call looks like the
cheapest thing in the graph.</p>
<p><strong>The supervisor is the expensive node, not the specialists.</strong> <code>billing</code> took 6ms
and no tokens. The router took 8.49s and 464. In a multi-agent graph, the
orchestration decisions are the cost centre.</p>
<p><strong>The routing decision is recorded as data.</strong> The supervisor's output in the trace
is the serialised <code>Command</code>:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"graph"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token null keyword" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"update"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"completed"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"supervisor-&gt;billing"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"resume"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token null keyword" style="color:#00009f">null</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"goto"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"billing"</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>And <code>billing</code>'s input contains <code>["supervisor-&gt;billing"]</code> — the <code>update</code> from that
<code>Command</code> arriving as the receiving agent's state. Handoff, cost and payload in one
frame.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>A trace shows the path taken, not the graph</div><div class="admonitionContent_BuS1"><p><code>scheduler</code> appears nowhere in this trace — not as a skipped span, not as a
disabled node. The Graph panel draws <code>__start__ → supervisor → billing → __end__</code>.
You cannot tell from a trace which alternatives existed, only which one ran, which
is a second reason the missing <code>Literal</code> annotation matters: the drawn graph is the
only place your alternatives are written down.</p></div></div>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Hand work between agents</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Part 3: the tools an agent is allowed to call. Everything above trusts each node
to do only what it should — the next post puts the tools behind a gateway with
per-tool permissions, so an agent's capabilities are granted rather than assumed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="importerror-install-grandalf-to-draw-graphs"><code>ImportError: Install grandalf to draw graphs</code><a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#importerror-install-grandalf-to-draw-graphs" class="hash-link" aria-label="Direct link to importerror-install-grandalf-to-draw-graphs" title="Direct link to importerror-install-grandalf-to-draw-graphs" translate="no">​</a></h3>
<p><code>draw_ascii()</code> needs a package LangGraph does not depend on. Use <code>draw_mermaid()</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-graph-diagram-shows-the-supervisor-going-straight-to-__end__">The graph diagram shows the supervisor going straight to <code>__end__</code><a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#the-graph-diagram-shows-the-supervisor-going-straight-to-__end__" class="hash-link" aria-label="Direct link to the-graph-diagram-shows-the-supervisor-going-straight-to-__end__" title="Direct link to the-graph-diagram-shows-the-supervisor-going-straight-to-__end__" translate="no">​</a></h3>
<p>The routing node is missing its <code>-&gt; Command[Literal[...]]</code> return annotation. The
code still runs; only the topology is wrong.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="invalidupdateerror-at-key-x-can-receive-only-one-value-per-step"><code>InvalidUpdateError: At key 'x': Can receive only one value per step</code><a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#invalidupdateerror-at-key-x-can-receive-only-one-value-per-step" class="hash-link" aria-label="Direct link to invalidupdateerror-at-key-x-can-receive-only-one-value-per-step" title="Direct link to invalidupdateerror-at-key-x-can-receive-only-one-value-per-step" translate="no">​</a></h3>
<p>Two nodes wrote the same key in one super-step and that key has no reducer. Either
give it one with <code>Annotated[T, fn]</code>, or stop dispatching both nodes in parallel.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="runtimeerror-cannot-schedule-new-futures-after-shutdown-after-ctrlc"><code>RuntimeError: cannot schedule new futures after shutdown</code> after Ctrl+C<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#runtimeerror-cannot-schedule-new-futures-after-shutdown-after-ctrlc" class="hash-link" aria-label="Direct link to runtimeerror-cannot-schedule-new-futures-after-shutdown-after-ctrlc" title="Direct link to runtimeerror-cannot-schedule-new-futures-after-shutdown-after-ctrlc" translate="no">​</a></h3>
<p>A completed branch's checkpoint write raced the executor shutting down. Check
<code>graph.get_state(config).values</code> before assuming anything was lost — in testing the
write had landed every time.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-reducer-produces-different-results-on-different-runs">The reducer produces different results on different runs<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#the-reducer-produces-different-results-on-different-runs" class="hash-link" aria-label="Direct link to The reducer produces different results on different runs" title="Direct link to The reducer produces different results on different runs" translate="no">​</a></h3>
<p>It is not order-independent. Parallel branches finish in arbitrary order, so a
reducer that depends on which value arrives second is non-deterministic by
construction.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/">Part 1 — checkpoint a LangGraph agent</a> — state that survives a crash</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/">Route by complexity</a> — what keyword routing misses, measured</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/">Component-level tracing for agent tool calls</a> — finding which step failed</li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>agents</category>
            <category>langgraph</category>
            <category>multi-agent</category>
            <category>state</category>
            <category>postgres</category>
            <category>langfuse</category>
            <category>self-hosting</category>
            <category>wec</category>
            <category>wec-inference</category>
        </item>
        <item>
            <title><![CDATA[Checkpoint a LangGraph agent on a WEC Instance so crashes cost you nothing]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/</guid>
            <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Persist agent state to Postgres on a WEC Instance, call a model on the WEC Inference API, then kill the process mid-run twice and resume both times from exactly where it stopped — plus the human-approval pause, and the interrupt that silently fires your side effects twice.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg xmlns="http://www.w3.org/2000/svg" width="487" height="249" fill="none" viewBox="0 0 487 249" class="tutorialHero__brand"><path fill="#1C3C3C" fill-rule="evenodd" d="M124.273.412h239.031c68.111 0 123.52 55.603 123.52 123.948s-55.409 123.948-123.52 123.948H124.273c-68.11 0-123.52-55.603-123.52-123.948S56.164.412 124.274.412M234.018 192.55c3.001 3.156 7.443 3 11.378 2.182l.039.02c1.827-1.486-.77-3.368-3.249-5.165-1.487-1.077-2.932-2.125-3.356-3.038 1.373-1.674-2.687-5.474-5.847-8.433-1.327-1.242-2.495-2.335-3.037-3.061-2.248-2.448-3.152-5.537-4.062-8.644-.603-2.061-1.208-4.131-2.211-6.027-6.176-14.339-13.248-28.561-23.165-40.699-6.373-8.067-13.646-15.291-20.922-22.518-4.691-4.66-9.382-9.319-13.835-14.206-4.581-4.73-7.338-10.556-10.1-16.392-2.312-4.886-4.627-9.779-8.018-14.04-10.268-15.196-42.686-19.346-47.44 2.124.019.662-.195 1.09-.78 1.52-2.63 1.928-4.968 4.11-6.935 6.76-4.812 6.721-5.553 18.118.448 24.158l.025-.388c.2-3.05.388-5.9 2.8-8.087 4.637 3.994 11.67 5.416 17.047 2.435 6.483 9.3 8.548 20.543 10.621 31.823 1.726 9.398 3.457 18.821 7.751 27.171l.266.443c2.524 4.202 5.089 8.472 8.326 12.142 1.176 1.823 3.59 3.791 6 5.755 3.18 2.591 6.353 5.177 6.663 7.416.015.974.01 1.961.006 2.954-.025 5.879-.051 11.967 3.716 16.801 2.084 4.228-3.02 8.475-7.131 7.949-2.254.312-4.716-.282-7.161-.871-3.346-.807-6.659-1.607-9.36-.064-.758.82-1.846.849-2.94.877-1.297.035-2.601.069-3.373 1.422-.158.402-.528.856-.913 1.328-.846 1.036-1.762 2.159-.665 3.016q.149-.111.294-.224c1.663-1.269 3.248-2.479 5.493-1.724-.299 1.658.772 2.103 1.843 2.547q.281.114.553.239c-.011.385-.087.774-.163 1.159-.18.921-.356 1.824.358 2.62.339-.344.639-.732.939-1.12.735-.95 1.474-1.904 2.802-2.25 2.92 3.9 5.862 2.28 9.554.248 4.164-2.293 9.281-5.111 16.396-1.125-2.727-.136-5.163.195-6.994 2.455-.448.507-.838 1.091-.039 1.754 4.21-2.728 5.961-1.748 7.61-.825 1.19.666 2.326 1.302 4.294.493.465-.242.93-.493 1.396-.745 3.161-1.705 6.366-3.434 10.118-2.839-2.803.808-3.8 2.584-4.888 4.524-.539.959-1.099 1.957-1.911 2.898-.429.429-.624.936-.137 1.656 5.869-.488 8.087-1.976 11.083-3.986 1.429-.959 3.036-2.038 5.302-3.183 2.505-1.542 5.009-.556 7.436.4 2.633 1.037 5.175 2.037 7.527-.264.743-.7 1.674-.708 2.602-.717a10 10 0 0 0 1.002-.043c-.732-3.92-4.861-3.874-9.052-3.827-4.847.054-9.778.109-9.632-5.972 4.504-3.076 4.546-8.415 4.585-13.461.01-1.218.019-2.419.091-3.567 3.313 1.847 6.817 3.291 10.299 4.725 3.276 1.35 6.533 2.692 9.593 4.354 3.195 5.143 8.182 11.962 14.826 11.514.175-.526.331-.974.526-1.5.383.067.787.169 1.199.273 1.743.442 3.611.915 4.509-1.15m130.213-58.45a20.54 20.54 0 0 0 14.504 5.994c5.44 0 10.658-2.156 14.505-5.994a20.44 20.44 0 0 0 6.007-14.469 20.44 20.44 0 0 0-6.007-14.469 20.54 20.54 0 0 0-21.882-4.624l-11.757-17.162-8.194 5.614 11.818 17.25a20.43 20.43 0 0 0-5.002 13.391 20.43 20.43 0 0 0 6.008 14.469m-36.808-55.576a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.784 20.47 20.47 0 0 0-7.873-10.09 20.557 20.557 0 0 0-18.35-2.282 20.5 20.5 0 0 0-7.964 5.175 20.44 20.44 0 0 0-4.77 8.201 20.429 20.429 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m0 118.824a20.55 20.55 0 0 0 21.408-1.964 20.46 20.46 0 0 0 7.34-10.483 20.4 20.4 0 0 0-.331-12.783 20.47 20.47 0 0 0-7.873-10.092 20.55 20.55 0 0 0-26.314 2.894 20.45 20.45 0 0 0-4.77 8.201 20.43 20.43 0 0 0 3.232 18.164 20.5 20.5 0 0 0 7.308 6.063m18.857-72.629v-10.174h-31.394a20.3 20.3 0 0 0-4.398-8.342L322.3 88.704l-8.59-5.698-11.812 17.499a20.5 20.5 0 0 0-6.749-1.221 20.5 20.5 0 0 0-14.462 5.959 20.3 20.3 0 0 0-5.99 14.388 20.3 20.3 0 0 0 5.99 14.387 20.5 20.5 0 0 0 14.462 5.96 20.5 20.5 0 0 0 6.749-1.221l11.812 17.498 8.487-5.697-11.709-17.498a20.3 20.3 0 0 0 4.398-8.342z" clip-rule="evenodd"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/Postgresql_elephant.svg-62fedb3464ae364ae4e342c86b474ed6.png" alt="PostgreSQL"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 4 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->4</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->4<!-- --> earned</span></div><div class="skillTracker__series">Agent orchestration with LangGraph</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">State that survives a restart</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/langgraph-multi-agent-handoff-state/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Hand work between agents</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-governed-tools-agent-elicitation/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Governed tools an agent can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/scoped-mcp-tools-per-agent/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Engineer the context, not the prompt</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Your agent has been working on a customer's request for forty seconds. It has read
the ticket, pulled the account, and checked three engineers' calendars. It is about
to book the appointment.</p>
<p>Then the process dies. A deploy going out, the host running out of memory, someone
restarting the container — it does not matter which.</p>
<p>The agent does not pick up where it left off, because there is nothing to pick up
from. Everything it learned lived in variables inside a process that no longer
exists. The customer is still waiting. Run it again and you pay for all that work a
second time. And the part that should worry you most: nobody can say whether the
appointment was booked in the last second before it died.</p>
<p>An agent is a model in a loop. As an ordinary Python script, that loop is exactly as
fragile as the process holding it.</p>
<p>Here we build one that saves its state to Postgres after every step, so a crash
costs nothing.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Verified versions: Python 3.10.12 · <code>langgraph</code> 1.2.11 ·
<code>langgraph-checkpoint</code> 4.2.0 · <code>langgraph-checkpoint-postgres</code> 3.1.2 ·
<code>psycopg</code> 3.3.4 · <code>psycopg-pool</code> 3.3.1 · <code>langchain-openai</code> 1.6.0 ·
<code>langfuse</code> 4.14.5 against Langfuse server v3.205.1 OSS · image <code>postgres:17</code>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-problem-before-the-solution">The problem, before the solution<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#the-problem-before-the-solution" class="hash-link" aria-label="Direct link to The problem, before the solution" title="Direct link to The problem, before the solution" translate="no">​</a></h2>
<p>What you want is a <strong>resumable</strong> run: each step's result written somewhere durable
the moment it finishes, so a new process can carry on from there.</p>
<p>That is what a checkpointer does.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-a-graph-and-not-a-loop">Why a graph and not a loop<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#why-a-graph-and-not-a-loop" class="hash-link" aria-label="Direct link to Why a graph and not a loop" title="Direct link to Why a graph and not a loop" translate="no">​</a></h3>
<p>A loop with a <code>try/except</code> and a few database writes could do this for four fixed
steps. It stops working the moment the agent picks its own path: when the model
chooses the next tool, "where are we?" has no answer you can write down.</p>
<p>LangGraph makes you declare the work as a <strong>graph</strong> — named nodes, explicit edges.
That feels like ceremony until you want durability.</p>
<table><thead><tr><th>A loop</th><th>A graph</th></tr></thead><tbody><tr><td>Steps are implicit in control flow</td><td>Steps are named nodes</td></tr><tr><td>"Where am I?" has no answer</td><td>Position is a value you can read</td></tr><tr><td>State lives in local variables</td><td>State is a declared object</td></tr><tr><td>Nothing to save</td><td>There is an obvious moment to save: node boundaries</td></tr></tbody></table>
<p>The runtime knows when a node starts and ends, so it has an obvious moment to save.
The state is a declared object, so there is something definite to write.</p>
<p>That is what "a deterministic state machine around a non-deterministic model" means:
the output is unpredictable, the next step is not. More on that debate in
<a class="" href="https://development-wec.wiline.com/docs/news/loop-engineering-graph-engineering-what-survives/">"Loop Engineering Is Dead"</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<!-- -->
<p>Three properties the usual examples do not have:</p>
<ul>
<li class=""><strong>state in Postgres</strong>, not in the process</li>
<li class=""><strong>a human approval gate</strong> the graph can sit at for days</li>
<li class=""><strong>every node traced</strong> as a span, with token counts and per-node latency</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A WEC Instance — <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/">deploy one</a> if you have not already</li>
<li class="">A WEC Inference API key — <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">create one</a> in the portal</li>
<li class="">A Langfuse instance — see <a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">Observe production with Langfuse</a></li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--install-and-why-three-packages">Step 1 — Install, and why three packages<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-1--install-and-why-three-packages" class="hash-link" aria-label="Direct link to Step 1 — Install, and why three packages" title="Direct link to Step 1 — Install, and why three packages" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> venv .venv</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">source</span><span class="token plain"> .venv/bin/activate</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">pip </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"langgraph"</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"langgraph-checkpoint-postgres"</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"psycopg[binary,pool]"</span><br></div></code></pre></div></div>
<table><thead><tr><th>Package</th><th>What it does</th><th>Why you need it here</th></tr></thead><tbody><tr><td><code>langgraph</code></td><td>The graph runtime — nodes, edges, state</td><td>The orchestration itself</td></tr><tr><td><code>langgraph-checkpoint-postgres</code></td><td>Writes state to Postgres</td><td><strong>The point of this tutorial</strong></td></tr><tr><td><code>psycopg[binary,pool]</code></td><td>Postgres driver + connection pool</td><td><code>PostgresSaver</code> requires a pool</td></tr></tbody></table>
<p>The middle row is worth pausing on. <strong>LangGraph's default persistence is
in-memory.</strong> Durability is opt-in, in a separate package you have to know to
install. That is precisely why so many published examples quietly lose state: they
never installed this, and nothing warned them.</p>
<p>Three names pull in more than three packages:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">./.venv/bin/pip list </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-iE</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"langgraph|psycopg"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">--version</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">langgraph                     1.2.11</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langgraph-checkpoint          4.2.0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langgraph-checkpoint-postgres 3.1.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langgraph-prebuilt            1.1.0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langgraph-sdk                 0.4.3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">psycopg                       3.3.4</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">psycopg-binary                3.3.4</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">psycopg-pool                  3.3.1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Python 3.10.12</span><br></div></code></pre></div></div>
<p><code>langgraph-checkpoint</code> is the one to notice: it is the abstract interface, and
<code>-postgres</code> is one implementation of it. That split is why swapping Postgres for
SQLite or Redis later changes one line.</p>
<p>The <code>pool</code> extra is not decoration either. Install plain <code>psycopg</code> and the failure
comes at connect time, not install time — which is a much more confusing place to
find it.</p>
<p><span class="zoomImage__wrap"><img alt="pip list showing the langgraph and psycopg packages with their versions and Python 3.10.12" src="https://development-wec.wiline.com/docs/assets/images/lg1-versions-21d23b4acd78bb5e757cf1a9f3949f97.png" width="1580" height="380" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> Eight packages from three names — the checkpoint interface and its
Postgres implementation arrive separately.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--a-database-for-checkpoints-not-for-the-app">Step 2 — A database for checkpoints, not for the app<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-2--a-database-for-checkpoints-not-for-the-app" class="hash-link" aria-label="Direct link to Step 2 — A database for checkpoints, not for the app" title="Direct link to Step 2 — A database for checkpoints, not for the app" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--name</span><span class="token plain"> lg-checkpoints </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">POSTGRES_USER</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">langgraph </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">POSTGRES_PASSWORD</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">changeme </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">POSTGRES_DB</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">checkpoints </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">127.0</span><span class="token plain">.0.1:5434:5432 postgres:17</span><br></div></code></pre></div></div>
<p>Three deliberate decisions:</p>
<p><strong>A dedicated database.</strong> Checkpoints are write-heavy — a row per node transition,
per thread, forever. Mixed into your application database they become a table you
are afraid to truncate. Separate, they are disposable.</p>
<p><strong><code>127.0.0.1:5434:5432</code>, not <code>-p 5434:5432</code>.</strong> The short form binds every
interface, and Docker writes its own iptables rules that bypass UFW — so a
firewall that looks correct is not protecting this port. Covered in
<a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Harden Docker networks</a>.</p>
<p>The left number is the host port, the right the container's. On an empty instance
use <code>5432:5432</code>. If you already self-host anything with a database, that port is
probably taken — pick any free one.</p>
<p><strong><code>postgres:17</code> pinned.</strong> <code>latest</code> means a reader six months from now runs
something you never tested.</p>
<p>Running is not the same as ready:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> lg-checkpoints pg_isready </span><span class="token parameter variable" style="color:#36acaa">-U</span><span class="token plain"> langgraph </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> checkpoints</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">/var/run/postgresql:5432 - accepting connections</span><br></div></code></pre></div></div>
<p>A container reports <code>Up</code> the instant it starts, while Postgres inside is still
initialising. Connecting too early gives a connection error that looks like a
configuration problem and is not.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-graph-deliberately-without-a-model">Step 3 — The graph, deliberately without a model<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-3--the-graph-deliberately-without-a-model" class="hash-link" aria-label="Direct link to Step 3 — The graph, deliberately without a model" title="Direct link to Step 3 — The graph, deliberately without a model" translate="no">​</a></h2>
<p>No LLM in this step. If something fails now, it is the checkpointer's fault and
not the model's — and separating those two is most of what debugging an agent
consists of.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-state-object">The state object<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#the-state-object" class="hash-link" aria-label="Direct link to The state object" title="Direct link to The state object" translate="no">​</a></h3>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> operator </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> add</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Annotated</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing_extensions </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> TypedDict</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p>This declares what the graph remembers. <code>TypedDict</code> gives the shape; the
<code>Annotated[..., add]</code> part is the interesting half.</p>
<p>By default, when a node returns a value for a key, that value <strong>replaces</strong> what
was there. <code>Annotated[list[str], add]</code> attaches a <strong>reducer</strong> — a function
combining the old value with the new one instead of overwriting it. With
<code>operator.add</code> on a list, that means append.</p>
<table><thead><tr><th>Declaration</th><th><code>step_one</code> returns <code>["a"]</code>, then <code>step_two</code> returns <code>["b"]</code></th></tr></thead><tbody><tr><td><code>completed: list[str]</code></td><td><code>["b"]</code> — the first result is lost</td></tr><tr><td><code>completed: Annotated[list[str], add]</code></td><td><code>["a", "b"]</code> — accumulates</td></tr></tbody></table>
<p>Get this wrong and your state silently forgets everything except the last node.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-nodes">The nodes<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#the-nodes" class="hash-link" aria-label="Direct link to The nodes" title="Direct link to The nodes" translate="no">​</a></h3>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">step_one</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">step_two</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two running — 30s window, KILL ME HERE"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">30</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>A node is a plain function. It receives the current state and returns a <strong>partial
update</strong> — only the keys it changed, not the whole object. The runtime merges that
update using the reducers you declared.</p>
<p>The thirty-second sleep in <code>step_two</code> stands in for a slow step — a model call, an
external API, a batch job. It is also what gives you time to kill the process on
cue in the next step.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="wiring-the-graph">Wiring the graph<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#wiring-the-graph" class="hash-link" aria-label="Direct link to Wiring the graph" title="Direct link to Wiring the graph" translate="no">​</a></h3>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">builder </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> fn </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_one</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_two</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_three</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>StateGraph(State)</code> binds the schema. <code>add_node</code> registers a function under a
name — that name is what appears in checkpoints and in traces later. <code>add_edge</code>
declares order, with <code>START</code> and <code>END</code> as the sentinels marking entry and exit.</p>
<p>This graph is a straight line. Branching comes in part 2.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="attaching-the-checkpointer">Attaching the checkpointer<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#attaching-the-checkpointer" class="hash-link" aria-label="Direct link to Attaching the checkpointer" title="Direct link to Attaching the checkpointer" translate="no">​</a></h3>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> PostgresSaver</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">from_conn_string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB_URI</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> checkpointer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    checkpointer</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">setup</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    graph </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">checkpointer</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Four lines, each carrying a trap.</p>
<p><strong><code>with ... as checkpointer</code>.</strong> <code>from_conn_string</code> is decorated <code>@contextmanager</code>
in the source — it <em>yields</em>, it does not return. Assigning it directly hands you a
context manager object where you expected a saver, and the error arrives much
later than the mistake.</p>
<p>It also opens the connection with <code>autocommit=True</code>, <code>prepare_threshold=0</code>, and
<code>row_factory=dict_row</code>. Pass your own connection and you must set those yourself, or
<code>.setup()</code> can look like it worked and save nothing. Why each is needed is documented
nowhere — see <a href="https://github.com/langchain-ai/langgraph/issues/4937" target="_blank" rel="noopener noreferrer" class="">issue #4937</a>,
closed without an answer.</p>
<p><strong><code>.setup()</code></strong> is required on first use. It creates the tables and runs
migrations.</p>
<p><strong><code>compile(checkpointer=...)</code>.</strong> Without this argument the graph runs perfectly and
saves nothing. There is no warning. It is the single most likely reason someone
believes checkpointing "doesn't work".</p>
<p><strong><code>thread_id</code>.</strong> The identifier for one conversation or one run. Every checkpoint
is scoped to it, and resuming means passing the same one. It must stay under 255
characters.</p>
<p>Together, that is <code>lg_agent.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">lg_agent.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> time</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> operator </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> add</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Annotated</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing_extensions </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> TypedDict</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">graph </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">checkpoint</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">postgres </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> PostgresSaver</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DB_URI </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"postgresql://langgraph:changeme@127.0.0.1:5434/checkpoints"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">THREAD </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"ticket-1"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RESUME </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"--resume"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">step_one</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">step_two</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two running — 30s window, KILL ME HERE"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">30</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">step_three</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_three running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> fn </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_one</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_two</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> step_three</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_one"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_two"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"step_three"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> PostgresSaver</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">from_conn_string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB_URI</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> checkpointer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    checkpointer</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">setup</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    graph </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">checkpointer</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    snap </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get_state</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"BEFORE  values=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">values</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  next=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation builtin">next</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> RESUME </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"FINAL:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    snap </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get_state</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"AFTER   values=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">values</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  next=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation builtin">next</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>RESUME</code> is what makes <code>--resume</code> work: on a resume the input is <code>None</code> rather
than a fresh state, which is covered in the next step.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent.py ticket-41</span><br></div></code></pre></div></div>
<p>After the first run, four tables exist:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> lg-checkpoints psql </span><span class="token parameter variable" style="color:#36acaa">-U</span><span class="token plain"> langgraph </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> checkpoints </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'\dt'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain"> public | checkpoint_blobs      | table | langgraph</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> public | checkpoint_migrations | table | langgraph</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> public | checkpoint_writes     | table | langgraph</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> public | checkpoints           | table | langgraph</span><br></div></code></pre></div></div>
<table><thead><tr><th>Table</th><th>Holds</th></tr></thead><tbody><tr><td><code>checkpoints</code></td><td>One row per node boundary — the snapshot index</td></tr><tr><td><code>checkpoint_blobs</code></td><td>The serialised state values</td></tr><tr><td><code>checkpoint_writes</code></td><td>Pending writes from nodes that finished in a super-step</td></tr><tr><td><code>checkpoint_migrations</code></td><td>Schema version bookkeeping</td></tr></tbody></table>
<p><code>checkpoint_writes</code> is the one that makes crash recovery work, as the next step
shows.</p>
<p><span class="zoomImage__wrap"><img alt="The four checkpoint tables created by setup()" src="https://development-wec.wiline.com/docs/assets/images/lg1-tables-f26befffa3adb092e0cecaa4126737c2.png" width="1520" height="380" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> <code>.setup()</code> writes the schema for you — no manual DDL.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="reading-position">Reading position<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#reading-position" class="hash-link" aria-label="Direct link to Reading position" title="Direct link to Reading position" translate="no">​</a></h3>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">snap </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get_state</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"BEFORE  values=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">values</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  next=</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">snap</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation builtin">next</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>get_state</code> returns a <code>StateSnapshot</code>. Two fields matter: <code>values</code> is the state
right now, and <code>next</code> is the tuple of nodes still to run. <code>next=()</code> means the
thread is finished or has never started; anything else names what is pending.</p>
<p><code>next</code> is how you answer "where is this run?" — the question a loop cannot answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--kill-it-and-watch-it-come-back">Step 4 — Kill it, and watch it come back<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-4--kill-it-and-watch-it-come-back" class="hash-link" aria-label="Direct link to Step 4 — Kill it, and watch it come back" title="Direct link to Step 4 — Kill it, and watch it come back" translate="no">​</a></h2>
<p>Start a fresh thread, then press Ctrl+C during the thirty-second window.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent.py ticket-42</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">BEFORE  values={}  next=()</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">step_one running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">step_two running — 30s window, KILL ME HERE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">^CKeyboardInterrupt</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="KeyboardInterrupt raised from inside step_two" src="https://development-wec.wiline.com/docs/assets/images/lg1-killed-13e4a54ded6e98bd000ca28160e0eb18.png" width="1594" height="858" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> A real crash — the traceback comes from inside <code>step_two</code>, which therefore never completed.</p>
<p>Now resume the same <code>thread_id</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent.py ticket-42 </span><span class="token parameter variable" style="color:#36acaa">--resume</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">BEFORE  values={'completed': ['step_one']}  next=('step_two',)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">step_two running — 30s window, KILL ME HERE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">step_three running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FINAL: {'completed': ['step_one', 'step_two', 'step_three']}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AFTER   values={'completed': [...]}  next=()</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The resumed run showing recovered state and next=(&amp;#39;step_two&amp;#39;,)" src="https://development-wec.wiline.com/docs/assets/images/lg1-resumed-060c0f536dc33b295570816403af58d0.png" width="1220" height="266" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The <code>BEFORE</code> line is the proof — state recovered from Postgres by a brand-new process.</p>
<p>Read that first line closely, because it is the whole tutorial:</p>
<ul>
<li class=""><code>values</code> shows <code>step_one</code>'s result, recovered from Postgres by a process that
had not existed when it was produced</li>
<li class=""><code>next=('step_two',)</code> — the graph knows exactly which node did not finish</li>
<li class=""><strong><code>step_one</code> did not run again.</strong> It executed once across three invocations</li>
</ul>
<p>That last point is what <code>checkpoint_writes</code> buys: LangGraph keeps the completed
writes from a super-step, so resuming does not repeat work that succeeded.</p>
<p>The resume call itself is:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>None</code> as input means "no new input — continue from the checkpoint." Worth
knowing: this is documented on the interrupts page for static breakpoints, but not
on the persistence or durable-execution pages, which is where you look when your
process has just died. It works; it is simply not written down where you need it.</p>
<p>Kill it a second time and resume again. It works, because reading a checkpoint
does not consume it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--pause-for-a-human">Step 5 — Pause for a human<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-5--pause-for-a-human" class="hash-link" aria-label="Direct link to Step 5 — Pause for a human" title="Direct link to Step 5 — Pause for a human" translate="no">​</a></h2>
<p>A crash is an accidental stop. An approval is a deliberate one — and once you can
survive the first, the second is nearly free.</p>
<p>The agent is about to write an appointment into a customer's calendar. That is
irreversible and a real person sees it, so you want someone to check first.</p>
<p>Without durable state, waiting for a human means holding a process open. With state
in Postgres the agent stops, the process exits, and the approval arrives whenever it
arrives — ten minutes or Monday morning.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">types </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> interrupt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Command</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">approval</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    decision </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> interrupt</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Approve step_two?"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"so_far"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"approval"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"approved"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> decision</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Save it as <code>lg_agent2.py</code> and run it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent2.py booking-7</span><br></div></code></pre></div></div>
<p><code>interrupt()</code> does two things at once: it stops the graph, and it hands its
argument out to whoever is calling. That argument is your question to the human —
any JSON-serialisable value.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">step_one running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RESULT: {'completed': ['step_one'], 'approved': '',</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">         '__interrupt__': [Interrupt(value={'question': 'Approve step_two?',</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">         'so_far': ['step_one']}, id='29f211cf5a73ea78e0f694e1bd38822b')]}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AFTER   next=('approval',)  interrupts=(Interrupt(...),)</span><br></div></code></pre></div></div>
<p>No exception, exit code 0. Note the difference from Step 4: the crash produced a
traceback, this produces a clean return.</p>
<p>The pause surfaces in two places, and the difference matters:</p>
<table><thead><tr><th>Where</th><th>Use</th></tr></thead><tbody><tr><td><code>__interrupt__</code> in the invoke result</td><td>The caller that just ran the graph</td></tr><tr><td><code>snapshot.interrupts</code></td><td>Any other process — a dashboard rendering the question</td></tr></tbody></table>
<p>The <code>id</code> correlates an answer back to the right pause, which matters once more
than one approval is outstanding.</p>
<p>The process is now gone; the state is in Postgres. An hour or a week later:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent2.py booking-7 </span><span class="token function" style="color:#d73a49">yes</span><br></div></code></pre></div></div>
<p>which calls:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">Command</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">resume</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"yes"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">approval resumed with: yes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">step_two running (approved=yes)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">AFTER   next=()  interrupts=()</span><br></div></code></pre></div></div>
<p><code>Command(resume=...)</code> is different from <code>invoke(None, ...)</code>. <code>None</code> says "carry
on"; <code>Command(resume=X)</code> says "carry on, and <code>interrupt()</code> should return <code>X</code>."
That value came from the command line, through <code>interrupt()</code>'s return, into state,
and was read by the next node.</p>
<p><span class="zoomImage__wrap"><img alt="The graph pausing at the interrupt, then resuming with the approval value" src="https://development-wec.wiline.com/docs/assets/images/lg1-interrupt-ea585f38dd6726bebaeeff4db224460a.png" width="2214" height="488" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> Pause and resume — two separate processes, one run.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-part-that-will-bite-you">The part that will bite you<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#the-part-that-will-bite-you" class="hash-link" aria-label="Direct link to The part that will bite you" title="Direct link to The part that will bite you" translate="no">​</a></h3>
<p>On resume, LangGraph re-executes the interrupted node <strong>from the top</strong>. This time
<code>interrupt()</code> returns your value instead of pausing — but everything above it in
that function has already run a second time.</p>
<p>Put a print before the interrupt, then run a fresh thread and approve it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent2.py booking-8</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_agent2.py booking-8 approve</span><br></div></code></pre></div></div>
<p>It appears in both runs, for a single approval:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">run 1:  step_one running</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        SIDE EFFECT — before interrupt</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        RESULT: {... '__interrupt__': [Interrupt(...)]}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">run 2:  SIDE EFFECT — before interrupt</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        approval resumed with: approve</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        step_two running (approved=approve)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same side-effect line printed in both the pausing run and the resuming run" src="https://development-wec.wiline.com/docs/assets/images/lg1-double-side-effect-89c6352b4d9d897ec50519025547fca8.png" width="2214" height="550" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> One approval, two executions of everything above the interrupt.</p>
<p>If that line were <code>send_email()</code> or <code>charge_card()</code>, one approval charges the
customer twice. Nothing raises. Final state is correct. Only the side effect
duplicated — which is why this is easy to ship and hard to notice.</p>
<p><strong>Put nothing before <code>interrupt()</code> in that node.</strong> Move side effects after it, or
give the interrupt a node of its own that does nothing else. The same reasoning
applies to crash resume: a node can always run more than once, so anything with an
external consequence should be idempotent or isolated.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--a-real-model-and-traces">Step 6 — A real model, and traces<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#step-6--a-real-model-and-traces" class="hash-link" aria-label="Direct link to Step 6 — A real model, and traces" title="Direct link to Step 6 — A real model, and traces" translate="no">​</a></h2>
<p>Two more packages: one to talk to the model, one to trace it.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">pip </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> langchain-openai langfuse</span><br></div></code></pre></div></div>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">llm </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>ChatOpenAI</code> is not OpenAI-specific — it speaks the OpenAI wire protocol, which
WEC Inference implements. Pointing <code>base_url</code> at it is the whole integration.
<code>temperature=0</code> keeps runs comparable while you are testing.</p>
<p>The call goes inside a node like any other work:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">write</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> llm</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"Write one sentence about </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">state</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'topic'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"write"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"draft"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>You need a Langfuse project and an API key pair for this — created under
<strong>Organization → Project</strong>, then <strong>Settings → API Keys</strong>, as covered in
<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">Observe production with Langfuse</a>.</p>
<p>Tracing attaches per invocation, in the same config dict as the thread id:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">langchain </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> CallbackHandler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">handler </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> CallbackHandler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"callbacks"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">handler</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><code>configurable</code> is LangGraph's own settings; <code>callbacks</code> is LangChain's observer
hook. Langfuse subscribes to node start and end events and builds spans from them
— which is why the trace mirrors your graph without any extra instrumentation.</p>
<p>Credentials come from the environment — in SDK v4 <code>CallbackHandler()</code> takes no
arguments, so this is the only way to configure it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'...'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_PUBLIC_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'pk-lf-...'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_SECRET_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'sk-lf-...'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_HOST</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'http://localhost:3001'</span><br></div></code></pre></div></div>
<p>Putting it together as <code>lg_llm.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> sys</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> operator </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> add</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Annotated</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing_extensions </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> TypedDict</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">graph </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langgraph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">checkpoint</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">postgres </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> PostgresSaver</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langchain_openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> ChatOpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">langchain </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> CallbackHandler</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DB_URI </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"postgresql://langgraph:changeme@127.0.0.1:5434/checkpoints"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">THREAD </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">argv</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"draft-1"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">llm </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ChatOpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TypedDict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    topic</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    completed</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Annotated</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> add</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    draft</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">plan</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"plan running"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"plan"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">write</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> llm</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"Write one sentence about </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">state</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'topic'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"model said:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"write"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"draft"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> StateGraph</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">State</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"plan"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> plan</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_node</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"write"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> write</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">START</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"plan"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"plan"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"write"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">add_edge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"write"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> END</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">handler </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> CallbackHandler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> PostgresSaver</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">from_conn_string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DB_URI</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> checkpointer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    checkpointer</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">setup</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    graph </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> builder</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">checkpointer</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">checkpointer</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    config </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"configurable"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"thread_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> THREAD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"callbacks"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">handler</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> graph</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">invoke</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"topic"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"edge computing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"completed"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"draft"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> config</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"FINAL:"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python lg_llm.py draft-1</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">LangGraph            2.18s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  plan               0.00s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  write              2.14s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    ChatOpenAI       2.13s   36 -&gt; 37 (73 tokens)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Langfuse trace with per-node latency and token counts" src="https://development-wec.wiline.com/docs/assets/images/lg1-langfuse-trace-8385ab962b94706bb03e2df32a87a0a6.png" width="2526" height="1426" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> Span tree, graph topology and token counts — from one callback.</p>
<p>Almost all of it is the model call. That leaves about 40ms for the graph and
tracing, and the gap stayed near 40ms even on runs that took twice as long. <code>plan</code>,
which calls nothing, reports 0.00s.</p>
<p>The useful conclusion: <strong>orchestration is not your latency problem.</strong> If an agent
feels slow, the graph is not why.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real-errors">Troubleshooting (real errors)<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#troubleshooting-real-errors" class="hash-link" aria-label="Direct link to Troubleshooting (real errors)" title="Direct link to Troubleshooting (real errors)" translate="no">​</a></h2>
<p><strong><code>address already in use</code> vs <code>port is already allocated</code>.</strong> These look
interchangeable and are not. The first means a <em>host process</em> holds the port; the
second means another <em>container</em> does — Docker's own bookkeeping, before the bind
is attempted. Diagnose with:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--format</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.Names}}\t{{.Ports}}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-ltnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> :543</span><br></div></code></pre></div></div>
<p><strong>A failed <code>docker run</code> blocks the name.</strong> The container is created, then dies, and
still holds its name — so your retry fails differently than the first attempt did.
<code>docker rm &lt;name&gt;</code> before retrying.</p>
<p><strong><code>ModuleNotFoundError: No module named 'langchain'</code></strong> when importing Langfuse's
<code>CallbackHandler</code>. Neither LangGraph nor <code>langchain-openai</code> needs the <code>langchain</code>
umbrella package; Langfuse imports it purely as a version sentinel. The fix is
installing a package nothing else in your stack uses.</p>
<p>Two upstream threads are worth knowing apart.
<a href="https://github.com/langfuse/langfuse/issues/9758" target="_blank" rel="noopener noreferrer" class="">#9758</a> reported the import
failure in October 2025 and is <strong>closed</strong> — but it still reproduces on the SDK
version above, so read it as history rather than as a fix that landed.
<a href="https://github.com/langfuse/langfuse/issues/13651" target="_blank" rel="noopener noreferrer" class="">#13651</a> is <strong>open</strong> and
proposes the actual remedy: read <code>langchain_core.__version__</code> instead of requiring
the umbrella package at all.</p>
<p><strong>Langfuse disables itself in silence.</strong> Miss an environment variable and you get
one warning line, then a completely normal run — exit code 0, correct output, no
traces at all:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Authentication error: Langfuse client initialized without public_key.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Client will be disabled.</span><br></div></code></pre></div></div>
<p>In SDK v4 <code>CallbackHandler()</code> accepts no constructor arguments, so environment
variables are the only configuration path — and a missing one does not raise.
Assert the client is enabled at startup rather than trusting the absence of errors.</p>
<p><strong>Version drift.</strong> Most LangGraph + Langfuse material targets Langfuse SDK v3. On
v4 the <code>update_trace</code> parameter is gone and raises <code>TypeError</code>, and credentials can
no longer be passed to the constructor.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>State that survives a restart</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/langgraph-stateful-agent-wec-instance/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Part 2: handing work between agents — and what happens to shared state when they
disagree.</p>
<p>Related reading on this site:</p>
<ul>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/">Component-level tracing for agent tool calls</a> — finding which step of an agent failed, not just that it did</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Self-host the Hermes Agent with persistent memory</a> — a different persistence model, memory across conversations rather than state within a run</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/news/loop-engineering-graph-engineering-what-survives/">"Loop Engineering Is Dead"</a> — why the industry moved from loops to graphs</li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>agents</category>
            <category>langgraph</category>
            <category>state</category>
            <category>postgres</category>
            <category>langfuse</category>
            <category>self-hosting</category>
            <category>wec</category>
            <category>wec-inference</category>
        </item>
        <item>
            <title><![CDATA[Clean traces, untouched answers: masking PII in LiteLLM's logs without corrupting the response]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/</guid>
            <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Send a gateway's traffic to Langfuse and the model's own reply carries the PII straight back into your traces. Widen Presidio's scope and it masks the answer your users receive instead. Neither setting gives you both, so here is a forty-line callback that does — measured, and with the mistake that quietly makes it slower.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"><span class="tutorialHero__plus">+</span><img class="tutorialHero__presidio" src="data:image/webp;base64,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" alt="Presidio"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 5 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->5</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->5<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting an LLM gateway</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">One endpoint, scoped keys</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Route work to the right model</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Mask PII at the gateway</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">4</span><span class="skillTracker__skill" data-state="current">Clean traces, untouched answers</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Prove it holds under load</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/">Part 3</a> configured Presidio to mask
prompts at logging time and deliberately stopped short of proving it. Nothing had
been wired to a logging destination yet, so there was no trace to inspect.</p>
<p>This post wires one up, finds <code>&lt;PERSON&gt;</code> where it should be — and then finds the
customer's real email address sitting a few lines below it, in the model's reply.</p>
<!-- -->
<!-- -->
<p>Everything in this post follows from where those two hooks sit. <code>post_call</code> fires
<em>before</em> the arrow back to the caller, so masking there rewrites the answer they
receive. <code>async_logging_hook</code> fires <em>after</em> it, on the branch to the logger only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="sending-the-gateways-traffic-to-langfuse">Sending the gateway's traffic to Langfuse<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#sending-the-gateways-traffic-to-langfuse" class="hash-link" aria-label="Direct link to Sending the gateway's traffic to Langfuse" title="Direct link to Sending the gateway's traffic to Langfuse" translate="no">​</a></h2>
<p>Langfuse is already running in this series; if you followed
<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">the observability tutorial</a> you
have an instance. Create a project for the gateway, then take its two keys from
<strong>Settings → API Keys</strong>.</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse project setup screen showing the secret and public keys" src="https://development-wec.wiline.com/docs/assets/images/gw4-langfuse-keys-0868edc9108438174e185d84a9801382.png" width="1905" height="992" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 1.</strong> A separate project keeps gateway traffic away from whatever else
you are tracing.</p>
<p>Add three variables to the gateway's <code>.env</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_PUBLIC_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">pk-lf-</span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_SECRET_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">sk-lf-</span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_HOST</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">http://your-host:3001</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The variable name is not the one Langfuse gives you</div><div class="admonitionContent_BuS1"><p>Langfuse's own setup screen prints a <code>.env</code> block containing
<code>LANGFUSE_BASE_URL</code>. The gateway does not read that. It reads:</p><div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"LANGFUSE_HOST"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://cloud.langfuse.com"</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div><p>Copy their snippet verbatim and your host is ignored in favour of the public
cloud endpoint — where your self-hosted keys will not authenticate. Traces go
nowhere, and nothing errors.</p></div></div>
<p>Then turn the callback on:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">litellm_settings</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">drop_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">success_callback</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"langfuse"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">failure_callback</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"langfuse"</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="restarting-is-not-enough">Restarting is not enough<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#restarting-is-not-enough" class="hash-link" aria-label="Direct link to Restarting is not enough" title="Direct link to Restarting is not enough" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart litellm</span><br></div></code></pre></div></div>
<p>Send a request after that and the logs say:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Langfuse client is disabled since no public_key was provided as a parameter</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">or environment variable 'LANGFUSE_PUBLIC_KEY'.</span><br></div></code></pre></div></div>
<p>The keys are in <code>.env</code>. They are not in the container:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway </span><span class="token function" style="color:#d73a49">printenv</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> LANGFUSE</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">0</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>restart</code> does not reload <code>env_file</code></div><div class="admonitionContent_BuS1"><p><code>docker compose restart</code> restarts the <em>process</em> inside the container that
already exists, with the environment it was created with. New variables in
<code>.env</code> are not picked up.</p><p>The <code>config.yaml</code> change <em>did</em> take effect, because that is a bind-mounted file
read at startup. So the gateway looks correctly configured — callbacks
initialised, no errors — while having no credentials at all.</p><p>Use <code>docker compose up -d</code>, which recreates the container.</p></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway </span><span class="token function" style="color:#d73a49">printenv</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> LANGFUSE</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">3</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="printenv inside the container now reporting three LANGFUSE variables" src="https://development-wec.wiline.com/docs/assets/images/gw4-recreate-env-8aff004f8b60f6c931690f76127937bf.png" width="363" height="71" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 2.</strong> After <code>up -d</code>, the variables are in the container.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trace-arrives-and-the-loop-closes">The trace arrives, and the loop closes<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#the-trace-arrives-and-the-loop-closes" class="hash-link" aria-label="Direct link to The trace arrives, and the loop closes" title="Direct link to The trace arrives, and the loop closes" translate="no">​</a></h2>
<p>Wait for <code>Application startup complete</code>, then send a prompt carrying a name and
an address:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">source</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","messages":[{"role":"user","content":"What is the first name of Maria Alvarez, and what is the domain of maria.alvarez@example.com? Answer in one short line."}],"max_tokens":40}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">The first name of Maria Alvarez is Maria; the domain of maria.alvarez@example.com is example.com.</span><br></div></code></pre></div></div>
<p>That answer is the control. The model could not have produced "Maria" from a
placeholder, so the live request reached it unmasked — exactly what
<code>logging_only</code> promises.</p>
<p>Now the trace:</p>
<p><span class="zoomImage__wrap"><img alt="A Langfuse trace showing the masked input above the unmasked assistant reply" src="https://development-wec.wiline.com/docs/assets/images/gw4-trace-input-masked-f167939e2680fdb4984817374cb86a8e.png" width="1899" height="989" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 3.</strong> Input masked. Output not.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Stored input</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">What is the first name of &lt;PERSON&gt;, and what is the domain of &lt;EMAIL_ADDRESS&gt;?</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Stored output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">The first name of Maria Alvarez is Maria; the domain of maria.alvarez@example.com is example.com.</span><br></div></code></pre></div></div>
<p>Part 3's job is done — the prompt is masked in the log while the model saw the
real text. And the PII is in the trace anyway, because the model repeated it
back.</p>
<p>That is not an edge case. Repeating the customer's name is what a support
assistant is <em>for</em>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-spend-log-is-worse">The spend log is worse<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#the-spend-log-is-worse" class="hash-link" aria-label="Direct link to The spend log is worse" title="Direct link to The spend log is worse" translate="no">​</a></h2>
<p>The gateway's own database stores the same request differently again:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/spend/logs </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.[0] | {messages, response}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"messages": {}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">"response": { ... "content": "The first name of Maria Alvarez is Maria;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">              the domain of maria.alvarez@example.com is example.com." ... }</span><br></div></code></pre></div></div>
<p>No prompt at all, and the completion in full. Two stores, two shapes, the same
hole in both.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="widening-the-scope-trades-one-problem-for-another">Widening the scope trades one problem for another<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#widening-the-scope-trades-one-problem-for-another" class="hash-link" aria-label="Direct link to Widening the scope trades one problem for another" title="Direct link to Widening the scope trades one problem for another" translate="no">​</a></h2>
<p>Part 3 set <code>presidio_filter_scope: input</code>. The obvious fix is to scan both
directions:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_filter_scope</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> both</span><br></div></code></pre></div></div>
<p>Restart, send the identical request, and read the response the caller gets:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">The first name of &lt;PERSON&gt; is &lt;PERSON&gt;; the domain of &lt;EMAIL_ADDRESS&gt; is &lt;URL&gt;.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The curl response containing placeholders instead of the real name and address" src="https://development-wec.wiline.com/docs/assets/images/gw4-response-masked-5e5f2e3cd767089449d5067827a9f518.png" width="390" height="192" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 4.</strong> The caller's own terminal, not a log viewer.</p>
<p>That is not the log. That is the answer returned to the client.</p>
<p><code>mode: logging_only</code> is still set. It did not help, because it never reaches the
output path — the documentation for the scope setting says so directly:</p>
<blockquote>
<p>Use <code>presidio_filter_scope: output</code> (or <code>both</code>) when you want Presidio to
actively scan and mask the model's response <strong>before it reaches the user</strong>.</p>
</blockquote>
<p>Masking the live response is the documented purpose of an output scope. What is
undocumented is the combination: nothing describes what happens when you set
<code>logging_only</code> <em>and</em> an output scope, and the code resolves it in favour of the
live path.</p>
<p>In <code>guardrail_initializers.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> run_output</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    output_callback </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> _make_presidio_callback</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        apply_to_output</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        event_hook</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">GuardrailEventHooks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post_call</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># hard-coded</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        output_parse_pii</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">False</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>mode</code> you configured is passed to the <em>input</em> callback. The output callback
gets <code>post_call</code> regardless.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Check the model still receives real data</div><div class="admonitionContent_BuS1"><p>A masked reply has two possible causes: the response was masked on the way out,
or the model received placeholders and answered honestly about them. They look
identical.</p><p>Ask for something that encodes the real value without containing it — a single
letter is not PII, so masking cannot hide it:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">. </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","messages":[{"role":"user","content":"Reply with ONLY the first letter of the first name of Maria Alvarez."}],"max_tokens":200}'</span><br></div></code></pre></div></div><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Mª</span><br></div></code></pre></div></div><p><code>M</code>. The model had the real name. Only the response was rewritten.</p></div></div>
<p>So the two settings give you a choice, and neither is the one you want:</p>
<table><thead><tr><th></th><th>Logged input</th><th>Logged output</th><th>Caller's response</th></tr></thead><tbody><tr><td><code>filter_scope: input</code></td><td>masked</td><td><strong>raw PII</strong></td><td>untouched</td></tr><tr><td><code>filter_scope: both</code></td><td>masked</td><td>masked</td><td><strong>masked</strong></td></tr></tbody></table>
<p>This has been reported.
<a href="https://github.com/BerriAI/litellm/issues/30447" target="_blank" rel="noopener noreferrer" class="">Issue #30447</a> — <em>"logging_only
Presidio guardrail corrupts user-facing response"</em> — was filed on 15 June 2026
and closed the next day, with no discussion, alongside a pull request titled
<em>"fix(presidio): don't mask the live request when guardrail is logging_only"</em>.
The report was about the response. The fix addressed the request.
<a href="https://github.com/BerriAI/litellm/issues/35951" target="_blank" rel="noopener noreferrer" class="">#35951</a> is still open on a
related path.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="doing-it-at-logging-time-instead">Doing it at logging time instead<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#doing-it-at-logging-time-instead" class="hash-link" aria-label="Direct link to Doing it at logging time instead" title="Direct link to Doing it at logging time instead" translate="no">​</a></h2>
<p>The gap exists because <code>post_call</code> runs while the response is still travelling
to the caller. There is a later hook that does not.</p>
<p><code>async_logging_hook</code> fires after the model has answered and before the loggers
run. It receives the request and the result, and <strong>whatever it returns is what
gets logged</strong>. The caller already has their response by then.</p>
<p>That hook is documented, under
<a href="https://docs.litellm.ai/docs/observability/scrub_data" target="_blank" rel="noopener noreferrer" class="">Scrub Logged Data</a> — but
the example there is a placeholder that replaces every message with the literal
string <code>MASK_THIS_ASYNC_VALUE</code>. It shows the hook exists; it does not mask
anything, it touches only <code>kwargs["messages"]</code>, and it mutates in place. What
follows uses the same extension point and adds the parts that make it work:
real Presidio calls, all three places PII hides, and a copy so the caller's
response survives.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Their example does not run as written</div><div class="admonitionContent_BuS1"><p>The snippet on that page ends <code>return kwargs, responses</code>, but the parameter is
named <code>result</code>. <code>responses</code> is undefined, so copying it verbatim raises
<code>NameError</code>.</p></div></div>
<p>Create <code>scrubber.py</code> next to <code>config.yaml</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">scrubber.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> copy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> typing </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> Any</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Tuple</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> httpx</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> litellm</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">integrations</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">custom_logger </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> CustomLogger</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ANALYZER </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"PRESIDIO_ANALYZER_API_BASE"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://presidio-analyzer:3000"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ANONYMIZER </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"PRESIDIO_ANONYMIZER_API_BASE"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://presidio-anonymizer:3000"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">_mask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">text</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> text </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> text</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> httpx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">AsyncClient</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">10.0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">ANALYZER</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/analyze"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"text"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"language"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"en"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        found </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> found</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> text</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">ANONYMIZER</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/anonymize"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"text"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"analyzer_results"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> found</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"text"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">PresidioLogScrubber</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">CustomLogger</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">async_logging_hook</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> kwargs</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Any</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> call_type</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Tuple</span><span class="token punctuation" style="color:#393A34">[</span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Any</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> call_type </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"completion"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"acompletion"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> kwargs</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> result</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        kwargs </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> copy</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">deepcopy</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> message </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> kwargs</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> _mask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        slo </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> kwargs</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"standard_logging_object"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">slo</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> message </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> slo</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> _mask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            response </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> slo</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"response"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">response</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">dict</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> choice </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> response</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"choices"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    message </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">choice </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                        message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> _mask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic"># `result` is the object the caller already holds. Copy before masking.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        logged_result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> copy</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">deepcopy</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">result</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> choice </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token builtin">getattr</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">logged_result</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"choices"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            message </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">getattr</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">choice</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> message </span><span class="token keyword" style="color:#00009f">is</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> </span><span class="token builtin">isinstance</span><span class="token punctuation" style="color:#393A34">(</span><span class="token builtin">getattr</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> _mask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> kwargs</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> logged_result</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">instance </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> PresidioLogScrubber</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Three details carry the whole thing.</p>
<p><strong>PII hides in three places, not one.</strong> <code>kwargs["messages"]</code> is the prompt.
<code>kwargs["standard_logging_object"]</code> is what the logging integrations actually
read — this is the subject of issue #35951. And <code>result</code> holds the completion.
Miss any one and something leaks.</p>
<p><strong><code>result</code> is the caller's object.</strong> Mutating it in place reproduces the bug we
are working around. It gets deep-copied first, and the copy is what we mask and
return.</p>
<p><strong><code>_mask</code> is async on purpose.</strong> More on that below, because getting it wrong
costs measurable latency.</p>
<p>Mount the file and register it. It must sit beside <code>config.yaml</code>, because
<code>get_instance_fn</code> resolves the dotted path relative to the config file's
directory:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./scrubber.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/scrubber.py</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">litellm_settings</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">drop_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">callbacks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"scrubber.instance"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">success_callback</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"langfuse"</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p>Set the built-in guardrail to <code>default_on: false</code> while testing. If both it and
the callback are masking, you cannot tell which did the work.</p>
<p>Adding a volume changes the container definition, so this needs a recreate, not
a restart:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-it-on-both-sides-of-one-request">Proving it, on both sides of one request<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#proving-it-on-both-sides-of-one-request" class="hash-link" aria-label="Direct link to Proving it, on both sides of one request" title="Direct link to Proving it, on both sides of one request" translate="no">​</a></h2>
<p>Send a request and keep the response body:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","messages":[{"role":"user","content":"Who is Priya Raghunathan and what is the domain of priya.raghunathan@example.net? One short line."}],"max_tokens":40}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> response.json</span><br></div></code></pre></div></div>
<p>What the caller received:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content'</span><span class="token plain"> response.json</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Priya Raghunathan is an AI researcher, and the domain</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">priya.raghunathan@example.net is likely a personal email address.</span><br></div></code></pre></div></div>
<p>What Langfuse stored:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$LANGFUSE_PUBLIC_KEY</span><span class="token string" style="color:#e3116c">:</span><span class="token string variable" style="color:#36acaa">$LANGFUSE_SECRET_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"http://your-host:3001/api/public/traces?limit=1"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> trace.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.data[0].output.content'</span><span class="token plain"> trace.json</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">&lt;PERSON&gt; is an AI researcher, and the domain &lt;EMAIL_ADDRESS&gt; is likely a</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">personal email address.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same sentence twice — the response with real values, the trace with placeholders" src="https://development-wec.wiline.com/docs/assets/images/gw4-side-by-side-54fd1e85dfe8ba2b6544c1a470fb77f9.png" width="448" height="317" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 5.</strong> Same sentence. One real, one masked.</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse trace with both input and output masked, alongside the unmasked response" src="https://development-wec.wiline.com/docs/assets/images/gw4-scrubber-working-29c3b666ef2aa43daef02208253f2fd4.png" width="1901" height="981" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 6.</strong> Masked on both sides of the trace, untouched for the caller.</p>
<p>Reading a rendered UI proves less than checking the payloads, so check them:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Priya Raghunathan"</span><span class="token plain"> response.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Priya Raghunathan"</span><span class="token plain"> trace.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PERSON"</span><span class="token plain"> trace.json </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">wc</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-l</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"One short line"</span><span class="token plain"> trace.json</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">1</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Four grep counts proving the response holds the real name and the trace does not" src="https://development-wec.wiline.com/docs/assets/images/gw4-grep-proof-f38350595a637c78223db0c9f01b0da1.png" width="448" height="177" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 7.</strong> The receipt: <code>1 0 2 1</code>.</p>
<p>The real name is in what the caller received. It is not in the trace. The
placeholder appears twice — input and output. And the last line proves both
files describe the <em>same</em> request, which the first three do not establish on
their own.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can mask what a gateway logs without changing what it returns, and prove it
by inspecting the raw payloads on both sides of a single request.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-version-that-quietly-costs-you-200-350ms">The version that quietly costs you 200-350ms<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#the-version-that-quietly-costs-you-200-350ms" class="hash-link" aria-label="Direct link to The version that quietly costs you 200-350ms" title="Direct link to The version that quietly costs you 200-350ms" translate="no">​</a></h2>
<p>The first working version of <code>_mask</code> used a blocking client — <code>httpx.Client</code>
inside an <code>async def</code>. It masked correctly. It also did this:</p>
<table><thead><tr><th></th><th>run 1</th><th>run 2</th></tr></thead><tbody><tr><td>no callback</td><td>0.74 s</td><td>—</td></tr><tr><td>blocking <code>httpx.Client</code></td><td>1.10 s</td><td>0.97 s</td></tr><tr><td>async <code>httpx.AsyncClient</code></td><td>0.77 s</td><td>0.77 s</td></tr></tbody></table>
<p>Warm medians, four sequential requests each, first discarded as cold. The async
figure repeated exactly; the blocking one did not, which is itself the point —
the penalty depends on what else the loop is doing.</p>
<p><span class="zoomImage__wrap"><img alt="Timed runs with the blocking client and with the async client" src="https://development-wec.wiline.com/docs/assets/images/gw4-timing-97e67eb7eb08fb9770f75d0b038bb147.png" width="386" height="224" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 8.</strong> Same request, same masking, one word different in the code.</p>
<p>A blocking HTTP call inside an async hook stalls the event loop until Presidio
answers, and each request makes several such calls. The masking is not on the
caller's critical path — but it is on everyone else's, because nothing else can
be serviced while it waits.</p>
<p>Changing <code>httpx.Client</code> to <code>httpx.AsyncClient</code> and awaiting the calls removes
the cost: 0.77s against a 0.74s baseline, inside the noise of the model call
itself, and it reproduced to the hundredth across two separate runs.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>What we did not test</div><div class="admonitionContent_BuS1"><p>These numbers are four sequential requests. Blocking the event loop barely
shows at one request at a time — it is under concurrency that the two versions
diverge, and we have not measured that here. If you run this at volume, load
test it before trusting the table above.</p><p>Each request also means two Presidio HTTP calls per message plus two for the
completion. At real volume Presidio becomes a service you size and monitor,
not a background detail.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-this-leaves-you">Where this leaves you<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#where-this-leaves-you" class="hash-link" aria-label="Direct link to Where this leaves you" title="Direct link to Where this leaves you" translate="no">​</a></h2>
<table><thead><tr><th></th><th>Logged input</th><th>Logged output</th><th>Caller's response</th><th>Cost</th></tr></thead><tbody><tr><td><code>filter_scope: input</code></td><td>masked</td><td>raw PII</td><td>untouched</td><td>none</td></tr><tr><td><code>filter_scope: both</code></td><td>masked</td><td>masked</td><td>masked</td><td>0.30s guardrail span</td></tr><tr><td>logging hook</td><td>masked</td><td>masked</td><td>untouched</td><td>none measurable</td></tr></tbody></table>
<p>Worth being clear about the limit of what this achieves: the PII still travels.
It reaches the model and it reaches the caller. What has been eliminated is
<em>retention</em> — it is no longer sitting in a trace store that a wider group of
people can read, months later, long after the request itself is gone.</p>
<p>Verified against <code>ghcr.io/berriai/litellm:main-stable</code>, admin UI reporting
v1.96.2, on 21 August 2026. If a later release adds a logging-scoped output
mode, prefer it over this.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="traces-never-appear-and-nothing-errors">Traces never appear and nothing errors<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#traces-never-appear-and-nothing-errors" class="hash-link" aria-label="Direct link to Traces never appear and nothing errors" title="Direct link to Traces never appear and nothing errors" translate="no">​</a></h3>
<p>Check <code>LANGFUSE_HOST</code> is set, not <code>LANGFUSE_BASE_URL</code>, and confirm the variables
are inside the container with <code>docker exec llm-gateway printenv | grep LANGFUSE</code>.
A <code>docker compose restart</code> will not have loaded them.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-callback-does-not-run-and-the-gateway-starts-normally">The callback does not run and the gateway starts normally<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#the-callback-does-not-run-and-the-gateway-starts-normally" class="hash-link" aria-label="Direct link to The callback does not run and the gateway starts normally" title="Direct link to The callback does not run and the gateway starts normally" translate="no">​</a></h3>
<p>A bad dotted path fails quietly. <code>scrubber.py</code> must sit in the same directory as
<code>config.yaml</code> — inside the container, not just on the host — and the object name
after the dot must exist. Grep the startup log for <code>ImportError</code> and
<code>AttributeError</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-response-comes-back-masked">The response comes back masked<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#the-response-comes-back-masked" class="hash-link" aria-label="Direct link to The response comes back masked" title="Direct link to The response comes back masked" translate="no">​</a></h3>
<p>The deep copy is missing, or the built-in guardrail is still enabled with an
output scope. Set <code>default_on: false</code> on it and confirm with
<code>curl /guardrails/list</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="everything-got-slower">Everything got slower<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#everything-got-slower" class="hash-link" aria-label="Direct link to Everything got slower" title="Direct link to Everything got slower" translate="no">​</a></h3>
<p>Check <code>_mask</code> uses <code>httpx.AsyncClient</code> with <code>await</code>, not <code>httpx.Client</code>. See the
measurements above.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Clean traces, untouched answers</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The measurements here are sequential, and the failure mode that cost 200-350ms
is one that only bites properly under concurrency. The next post puts the gateway
under parallel load and measures what actually happens to latency when several
requests contend for the same event loop.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/guardrails/pii_masking_v2" target="_blank" rel="noopener noreferrer" class="">PII, PHI Masking — Presidio</a></li>
<li class=""><a href="https://docs.litellm.ai/docs/observability/scrub_data" target="_blank" rel="noopener noreferrer" class="">Scrub Logged Data</a> — the documented hook this post builds on</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/">Part 3 — mask PII at the gateway</a></li>
</ul>]]></content:encoded>
            <category>llm</category>
            <category>gateway</category>
            <category>pii</category>
            <category>privacy</category>
            <category>guardrails</category>
            <category>observability</category>
            <category>langfuse</category>
            <category>litellm</category>
            <category>presidio</category>
            <category>wec</category>
        </item>
        <item>
            <title><![CDATA[Mask PII at the gateway: set up Presidio, plus the one line the docs leave out]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/</guid>
            <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Set up PII masking on a self-hosted LLM gateway with Presidio. Masking can sit in three places — before the model, on the response, or only on the path to your logs — and only one keeps your app working. Follow the documented config and your model's answers come back redacted; here is why, and the single setting that fixes it.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"><span class="tutorialHero__plus">+</span><img class="tutorialHero__presidio" src="data:image/webp;base64,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" alt="Presidio"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 5 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->5</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->5<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting an LLM gateway</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">One endpoint, scoped keys</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Route work to the right model</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">3</span><span class="skillTracker__skill" data-state="current">Mask PII at the gateway</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Clean traces, untouched answers</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Prove it holds under load</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/">Part 2</a> ended with the gateway deciding
which model answers each request — and still forwarding every prompt verbatim,
including the ones carrying customer names, emails and phone numbers.</p>
<p>This post puts a PII filter in that path. Not in front of the model, though:
<a class="" href="https://development-wec.wiline.com/docs/news/mask-your-logs-not-your-prompts/">in front of the logs</a>. The model reading
your prompt is doing its job; the risk is what gets <strong>stored</strong>. So the raw text
goes to the model and a masked copy goes to your logging.</p>
<p>That is what the gateway advertises. Following its documented configuration got
me the opposite — a model that answered correctly and a reply that came back as
<code>&lt;LOCATION&gt;</code>. This is how to find that and how to fix it.</p>
<!-- -->
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Version tested</div><div class="admonitionContent_BuS1"><p>LiteLLM <code>1.96.2</code> (<code>ghcr.io/berriai/litellm:main-stable</code>), Presidio images from
<code>ghcr.io/data-privacy-stack</code>, models <code>qwen-small</code> (Qwen2.5-3B-Instruct) and
<code>qwen-mid</code> (Qwen3.5-9B) via the WEC Inference API, on 20 August 2026. Defaults
change — if your results differ, check your version first.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-presidio-is-and-where-it-goes">What Presidio is, and where it goes<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#what-presidio-is-and-where-it-goes" class="hash-link" aria-label="Direct link to What Presidio is, and where it goes" title="Direct link to What Presidio is, and where it goes" translate="no">​</a></h2>
<p><a href="https://github.com/data-privacy-stack/presidio" target="_blank" rel="noopener noreferrer" class="">Presidio</a> detects personal data
in text: names, emails, phone numbers, card numbers. You POST it a string and it
returns the entities it found, with character offsets and a confidence score.</p>
<p>It ships as two services — an <strong>analyzer</strong> that finds PII and an <strong>anonymizer</strong>
that replaces it. The gateway calls both.</p>
<p>One naming note that trips people up: Presidio was Microsoft's project and most
tutorials still call it that. The repository moved to the <code>data-privacy-stack</code>
organisation, and <code>github.com/microsoft/presidio</code> now answers <code>Moved Permanently</code>. Same project, new home, which is why the images below aren't under
a Microsoft path.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="add-presidio-to-the-gateways-compose">Add Presidio to the gateway's compose<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#add-presidio-to-the-gateways-compose" class="hash-link" aria-label="Direct link to Add Presidio to the gateway's compose" title="Direct link to Add Presidio to the gateway's compose" translate="no">​</a></h2>
<p>Presidio only ever talks to the gateway, so it does not need published ports.
Open the <code>docker-compose.yml</code> from Part 1 and add two services after the <code>db:</code>
block, above <code>volumes:</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">presidio-analyzer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ghcr.io/data</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">privacy</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stack/presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">analyzer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">latest</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">container_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">analyzer</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">presidio-anonymizer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ghcr.io/data</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">privacy</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stack/presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">anonymizer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">latest</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">container_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">anonymizer</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div></code></pre></div></div>
<p>Two spaces of indentation, same level as <code>litellm:</code> and <code>db:</code>.</p>
<p>Notice what is absent: no <code>ports:</code>. Putting them in the same compose project as
the gateway means Docker gives them a shared network and DNS, so the gateway
reaches them by service name and nothing else on your network can reach them at
all. If you deployed Presidio separately with <code>ports: ["5002:3000"]</code>, that
exposed your PII analyzer to everything that can route to the host — worth
undoing.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p>Four containers, and the Presidio rows should show <code>3000/tcp</code> with no host
mapping:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NAME                  SERVICE               STATUS         PORTS</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">llm-gateway           litellm               Up             127.0.0.1:4000-&gt;4000/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">llm-gateway-db        db                    Up (healthy)   5432/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">presidio-analyzer     presidio-analyzer     Up (healthy)   3000/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">presidio-anonymizer   presidio-anonymizer   Up (healthy)   3000/tcp</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Four containers running, with both Presidio services showing 3000/tcp and no host port mapping" src="https://development-wec.wiline.com/docs/assets/images/gw3-compose-ps-57ff2d738b0cb45457ecc59f8325613d.png" width="1732" height="340" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>If <code>docker compose ps</code> shows only two containers, the compose file was not saved.
Confirm what compose is actually reading:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose config </span><span class="token parameter variable" style="color:#36acaa">--services</span><br></div></code></pre></div></div>
<p>That must list four names. It reads the file on disk, so it catches an edit that
never made it out of your editor.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="check-the-analyzer-and-learn-its-limits">Check the analyzer, and learn its limits<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#check-the-analyzer-and-learn-its-limits" class="hash-link" aria-label="Direct link to Check the analyzer, and learn its limits" title="Direct link to Check the analyzer, and learn its limits" translate="no">​</a></h2>
<p>Before wiring anything, see what Presidio actually finds:</p>
<p>There are no host ports now, so ask from inside the gateway. The LiteLLM image
ships no <code>curl</code>, so use its Python:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import json,urllib.request as u; r=u.Request('http://presidio-analyzer:3000/analyze', data=json.dumps({'text':'Call Rafael on 555-0142 or rafael@example.com','language':'en'}).encode(), headers={'Content-Type':'application/json'}); print(u.urlopen(r).read().decode())"</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"entity_type"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"EMAIL_ADDRESS"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1.0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"start"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">27</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"end"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">45</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"entity_type"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PERSON"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.85</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"start"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"end"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">11</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"entity_type"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"URL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"start"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">34</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"end"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">45</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Analyzer response scoring the email at 1.0 and the name at 0.85, with no phone number detected" src="https://development-wec.wiline.com/docs/assets/images/gw3-analyze-scores-16b5f56ac49060ffafbba8dc8a2c2333.png" width="1638" height="488" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Read that carefully, because it sets expectations for everything after.</p>
<p>It found the email at full confidence and the name at 0.85. <strong>It did not find the
phone number at all.</strong> <code>555-0142</code> is seven digits with no area code, and
Presidio validates against real numbering plans rather than pattern-matching
anything that looks phone-shaped. Add an area code:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import json,urllib.request as u; r=u.Request('http://presidio-analyzer:3000/analyze', data=json.dumps({'text':'Call Rafael on (415) 555-0142 or rafael@example.com','language':'en'}).encode(), headers={'Content-Type':'application/json'}); print(u.urlopen(r).read().decode())"</span><br></div></code></pre></div></div>
<p>Now <code>PHONE_NUMBER</code> appears — scoring <strong>0.4</strong>, against 1.0 for the email.</p>
<p>It is worth checking whether the <code>555</code> prefix is the problem, since that range is
reserved for fiction. It is not. A plausible number behaves identically:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> llm-gateway python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import json,urllib.request as u; r=u.Request('http://presidio-analyzer:3000/analyze', data=json.dumps({'text':'Call Rafael on (415) 682-4531 or on 682-4531','language':'en'}).encode(), headers={'Content-Type':'application/json'}); print(u.urlopen(r).read().decode())"</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"entity_type"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PERSON"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.85</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"start"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"end"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">11</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"entity_type"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PHONE_NUMBER"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.4</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"start"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">15</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"end"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">29</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p>Same 0.4 for the full number, and the bare <code>682-4531</code> is still missed. So two
things are true: an area code is what makes a phone number detectable at all,
and <strong>0.4 is simply what this recognizer returns for phone numbers</strong> — not a
penalty for fake ones.</p>
<p>Which means entity types are not equally trustworthy, and confidence is a number
you will want to set deliberately. Presidio exposes <code>presidio_score_thresholds</code>
for exactly that: a per-entity floor below which detections are discarded. Set it
at 0.5 and you would silently stop masking every phone number in your traffic.</p>
<p>Do not build a compliance story on the assumption that this catches everything.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="teaching-it-the-numbers-it-misses">Teaching it the numbers it misses<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#teaching-it-the-numbers-it-misses" class="hash-link" aria-label="Direct link to Teaching it the numbers it misses" title="Direct link to Teaching it the numbers it misses" translate="no">​</a></h3>
<p>There is an escape hatch, and it is worth knowing before you decide the coverage
is unacceptable. <code>presidio_ad_hoc_recognizers</code> takes a path to a JSON file of
extra recognizers, loaded when the guardrail starts:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">recognizers.json</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"LocalPhoneRecognizer"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"supported_language"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"en"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"supported_entity"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PHONE_NUMBER"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"patterns"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"us-local-7"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"regex"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"\\b\\d{3}-\\d{4}\\b"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.9</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p>Mount it beside the config and point the guardrail at it:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./recognizers.json</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/recognizers.json</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_ad_hoc_recognizers</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> /app/recognizers.json</span><br></div></code></pre></div></div>
<p>Recreate the container — a new volume needs <code>up -d</code>, not <code>restart</code> — and the same
prompt that leaked now masks:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Reply OK. Contact &lt;PERSON&gt; on &lt;PHONE_NUMBER&gt; about the invoice.</span><br></div></code></pre></div></div>
<p><strong>Why the score is 0.9 and not 0.8.</strong> Put <code>682-4531</code> inside a sentence —
<code>Call me on 682-4531</code> — and the default recognizers return <code>DATE_TIME</code> at
<strong>0.85</strong>: seven digits with a dash, read as a date. (Pass the number on its own,
with no sentence around it, and nothing fires at all; the NER model wants
context before it commits to anything.) At 0.8 your phone recognizer loses the
overlap and the number is masked as <code>&lt;DATE_TIME&gt;</code>: still redacted, but filed
under the wrong entity, which quietly breaks any reporting that counts by type.
At 0.86 or above <code>PHONE_NUMBER</code> wins.</p>
<p>Measured across three runs each, entirely deterministic:</p>
<table><thead><tr><th>Text</th><th>Default</th><th>Recognizer at 0.8</th><th>Recognizer at 0.9</th></tr></thead><tbody><tr><td><code>Call me on 555-0142</code></td><td><strong>left in the clear</strong></td><td><code>&lt;PHONE_NUMBER&gt;</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>Call me on 682-4531</code></td><td><code>&lt;DATE_TIME&gt;</code></td><td><code>&lt;DATE_TIME&gt;</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>(415) 682-4531 or on 682-4531</code></td><td>bare one <strong>leaks</strong></td><td>both masked</td><td>both masked</td></tr></tbody></table>
<p>Note the middle row is not a miss — it is a misclassification, and the anonymizer
still redacts it. The first and third rows are the real leaks, and a recognizer
closes both.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-version-above-is-not-safe-to-ship">The version above is not safe to ship<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#the-version-above-is-not-safe-to-ship" class="hash-link" aria-label="Direct link to The version above is not safe to ship" title="Direct link to The version above is not safe to ship" translate="no">​</a></h3>
<p>Seven digits and a dash is a shape, not a meaning, and plenty of things share it.
Run that recognizer against text from an actual support queue:</p>
<table><thead><tr><th>Text</th><th>Masked as</th></tr></thead><tbody><tr><td><code>Order 482-1099 shipped yesterday</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>Invoice 100-2000 is overdue</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>Part number 250-4000 is discontinued</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>The error code was 500-1001</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>Our office is at 200-4500 Main Street</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr><tr><td><code>RFC 793-1981 defines TCP</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td></tr></tbody></table>
<p>Six for six. And the 0.9 you set to win the <code>DATE_TIME</code> overlap now wins <em>every</em>
overlap, so the recognizer does not merely add noise — it overwrites correct
classifications with a wrong one. Traces full of <code>&lt;PHONE_NUMBER&gt;</code> where the order
numbers used to be are worse than traces with one number in the clear, because
now you cannot tell which is which.</p>
<p>The fix is to stop matching on shape alone and require a cue word in front of the
digits. That has to be a <strong>lookbehind</strong>: Presidio redacts the whole match, so
<code>(call\W+)(\d{3}-\d{4})</code> would swallow the word "call" along with the number —
capture groups do not narrow the span. Python's <code>re</code> only allows fixed-width
lookbehinds, which is why this is an alternation rather than one tidy list:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">recognizers.json</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"LocalPhoneRecognizer"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"supported_language"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"en"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"supported_entity"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"PHONE_NUMBER"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"patterns"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token property" style="color:#36acaa">"name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"us-local-7-cued"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token property" style="color:#36acaa">"regex"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"(?i)(?:(?&lt;=call )|(?&lt;=called )|(?&lt;=calling )|(?&lt;=phone )|(?&lt;=phone: )|(?&lt;=phone is )|(?&lt;=tel )|(?&lt;=tel: )|(?&lt;=cell )|(?&lt;=cell: )|(?&lt;=cell is )|(?&lt;=mobile )|(?&lt;=mobile: )|(?&lt;=contact )|(?&lt;=contact: )|(?&lt;=number )|(?&lt;=number: )|(?&lt;=number is )|(?&lt;=me on )|(?&lt;=him on )|(?&lt;=her on )|(?&lt;=them on )|(?&lt;=us on )|(?&lt;=me at )|(?&lt;=him at )|(?&lt;=her at )|(?&lt;=them at )|(?&lt;=us at ))(?&lt;!part number )(?&lt;!serial number )(?&lt;!order number )(?&lt;!invoice number )(?&lt;!account number )(?&lt;!model number )(?&lt;!tracking number )(?&lt;!reference number )(?&lt;!batch number )(?&lt;!ticket number )(?&lt;!case number )\\d{3}-\\d{4}\\b"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token property" style="color:#36acaa">"score"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.9</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><br></div></code></pre></div></div>
<p>The trailing negative lookbehinds are there because <code>number </code> is a useful cue and
<code>part number </code> is not.</p>
<p>Measured over twelve phone phrasings and twelve lookalikes:</p>
<table><thead><tr><th></th><th><code>\b\d{3}-\d{4}\b</code></th><th>Cue-anchored</th></tr></thead><tbody><tr><td>Phone numbers masked</td><td>12/12</td><td>12/12</td></tr><tr><td>Lookalikes wrongly masked</td><td><strong>12/12</strong></td><td><strong>0/12</strong></td></tr></tbody></table>
<p>Two things this does not fix, and you should know both. <code>Order 482-1099</code> still
comes back as <code>&lt;DATE_TIME&gt;</code> — that is spaCy misfiring on its own, with or without
your recognizer. And a phone number introduced by a phrase you did not think of
goes back to leaking. A cue list is a guess about how people write, so treat it
as something to revisit against your own traffic rather than a finished artefact.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Brazilian numbers fail the same way</div><div class="admonitionContent_BuS1"><p><code>BR</code> is in the default region list, so <code>(11) 91234-5678</code> is recognised — and
<code>91234-5678</code> without the DDD is not, exactly as with a missing US area code. The
same recognizer closes it with <code>\d{4,5}-\d{4}</code> and a Portuguese cue list.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wire-the-guardrail">Wire the guardrail<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#wire-the-guardrail" class="hash-link" aria-label="Direct link to Wire the guardrail" title="Direct link to Wire the guardrail" translate="no">​</a></h2>
<p>The gateway calls Presidio through a guardrail. Open <code>config.yaml</code> and add a
<code>guardrails:</code> block at the end — this is the configuration the LiteLLM
documentation gives for masking only on the logging path:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">guardrails</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">guardrail_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">log</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">mask</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">guardrail</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">mode</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> logging_only</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">default_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_analyzer_api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> http</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">analyzer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">3000</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_anonymizer_api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> http</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">anonymizer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">3000</span><br></div></code></pre></div></div>
<p><code>mode: logging_only</code> is the important one. The documentation describes it as:</p>
<blockquote>
<p>Run after LLM call, only apply PII Masking before logging to Langfuse, etc.
Not on the actual llm api request / response.</p>
</blockquote>
<p><code>default_on: true</code> applies it to every request rather than only those that ask
for it by name. And the two <code>api_base</code> values are the service names from the
compose file — the guardrail runs inside the gateway container, so <code>localhost</code>
would point at the wrong place.</p>
<p>Restart, and wait for the proxy to actually come back — it takes 30 to 60
seconds, which is longer than most <code>sleep</code> commands people put in front of it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart litellm</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">until</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sf</span><span class="token plain"> localhost:4000/health/readiness </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> </span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.'</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">" ready"</span><br></div></code></pre></div></div>
<p>Then confirm the guardrail loaded, which is more reliable than reading logs:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-m1</span><span class="token variable" style="color:#36acaa"> LITELLM_MASTER_KEY ~/llm-gateway/.env </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">cut</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa"> -f2- </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'"'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:4000/guardrails/list </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import sys,json; g=json.load(sys.stdin)['guardrails'][0]; p=g['litellm_params']; print(json.dumps({'guardrail_name': g['guardrail_name'], **{k: p.get(k) for k in ('guardrail','mode','presidio_filter_scope','default_on','presidio_analyzer_api_base','presidio_anonymizer_api_base','fail_on_error','unreachable_fallback')}}, indent=2))"</span><br></div></code></pre></div></div>
<p>The endpoint returns every possible guardrail field, most of them <code>null</code>, so this
picks out the ones that matter:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"guardrail_name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"presidio-log-mask"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"guardrail"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"presidio"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"mode"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"logging_only"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"presidio_filter_scope"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"input"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"default_on"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"presidio_analyzer_api_base"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://presidio-analyzer:3000"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"presidio_anonymizer_api_base"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://presidio-anonymizer:3000"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"fail_on_error"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"unreachable_fallback"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"fail_closed"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Those last two are the defaults worth knowing before this sees real traffic: if
Presidio is unreachable, requests fail rather than passing unmasked.</p>
<p><span class="zoomImage__wrap"><img alt="The guardrails list endpoint reporting presidio-log-mask loaded, with mode logging_only and both Presidio API bases resolved" src="https://development-wec.wiline.com/docs/assets/images/gw3-guardrail-list-cd2a45b09b50cc051970b5b85feba496.png" width="1632" height="732" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-it-and-get-a-surprise">Test it, and get a surprise<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#test-it-and-get-a-surprise" class="hash-link" aria-label="Direct link to Test it, and get a surprise" title="Direct link to Test it, and get a surprise" translate="no">​</a></h2>
<p>Now the part that matters. Send a prompt whose <em>answer</em> proves whether the model
saw the real text — asking it to echo the PII back is useless, because the reply
gets scanned too:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-m1</span><span class="token variable" style="color:#36acaa"> LITELLM_MASTER_KEY ~/llm-gateway/.env </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">cut</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa"> -f2- </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'"'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","temperature":0,"messages":[{"role":"user","content":"Which US city does this area code belong to: (415) 555-0142? Answer with only the city name."}]}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import sys,json; print(json.load(sys.stdin)['choices'][0]['message']['content'])"</span><br></div></code></pre></div></div>
<p>Expected: <code>San Francisco</code>, proving the model read the digits.</p>
<p>Actual:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">&lt;LOCATION&gt;</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The model answering a question that requires reading the area code, and the answer returned as the placeholder LOCATION" src="https://development-wec.wiline.com/docs/assets/images/gw3-masked-answer-1467b181d83013e63ad42b11ffcdfa95.png" width="1416" height="248" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The model answered a question that requires reading a real area code — and then
the answer itself came back masked. <code>logging_only</code> was supposed to stay off the
actual response.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-that-happens">Why that happens<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#why-that-happens" class="hash-link" aria-label="Direct link to Why that happens" title="Direct link to Why that happens" translate="no">​</a></h2>
<p>There is a second setting, and its default is doing the work.</p>
<p><code>presidio_filter_scope</code> controls which direction gets scanned. From the
documentation:</p>
<blockquote>
<p><code>input</code>: only user → model content is scanned; <code>output</code>: only model → user
content is scanned; <code>both</code> (default): scan both directions</p>
</blockquote>
<p>And, on the same page:</p>
<blockquote>
<p>Use <code>presidio_filter_scope: output</code> (or <code>both</code>) when you want Presidio to
actively scan and mask the model's response before it reaches the user.</p>
</blockquote>
<p>So <code>both</code> — the default — actively masks responses. The documented <code>logging_only</code>
example does not set <code>presidio_filter_scope</code> at all, which leaves it at <code>both</code>.
The result is a configuration that masks the thing the same page says it will not
touch.</p>
<p>In the source, <code>initialize_presidio</code> reads that scope and registers a second
callback with <code>apply_to_output=True</code> on <code>post_call</code> whenever output scanning is
enabled. That callback's event hook is hard-coded, so the <code>mode</code> you set never
reaches it.</p>
<p>This has been reported.
<a href="https://github.com/BerriAI/litellm/issues/30447" target="_blank" rel="noopener noreferrer" class="">Issue #30447</a>, <em>"logging_only
Presidio guardrail corrupts user-facing response (assistant content replaced with
PII tokens)"</em>, was filed on 15 June 2026 and closed the next day with no
discussion, alongside a pull request titled <em>"fix(presidio): don't mask the live
request when guardrail is logging_only"</em>. The report was about the response; the
fix addressed the request. On the <code>main-stable</code> image used here, the behaviour
above still reproduces.</p>
<p>A second report is open:
<a href="https://github.com/BerriAI/litellm/issues/35951" target="_blank" rel="noopener noreferrer" class="">#35951</a> notes that in
<code>logging_only</code> mode the masking hook rewrites <code>kwargs["messages"]</code> but does not
propagate to <code>standard_logging_object</code>, which is what downstream loggers read.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-fix">The fix<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#the-fix" class="hash-link" aria-label="Direct link to The fix" title="Direct link to The fix" translate="no">​</a></h2>
<p>One line. Add <code>presidio_filter_scope: input</code> to the guardrail:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">guardrails</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">guardrail_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">log</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">mask</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">guardrail</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> presidio</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">mode</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> logging_only</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_filter_scope</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> input</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">default_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_analyzer_api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> http</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">analyzer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">3000</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">presidio_anonymizer_api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> http</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//presidio</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">anonymizer</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">3000</span><br></div></code></pre></div></div>
<p>Restart, wait for readiness, and run the same request:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">San Francisco</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can run Presidio beside a gateway, mask prompts at logging time without
touching the live request, and tell the two directions apart with
<code>presidio_filter_scope</code>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-it-properly">Proving it properly<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#proving-it-properly" class="hash-link" aria-label="Direct link to Proving it properly" title="Direct link to Proving it properly" translate="no">​</a></h2>
<p>One right answer could be a lucky guess — "San Francisco" is a plausible default
for a masked area-code question, and <code>temperature: 0</code> makes a guess repeat just
as reliably as a real answer. So ask about several area codes the model cannot
bluff:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-m1</span><span class="token variable" style="color:#36acaa"> LITELLM_MASTER_KEY ~/llm-gateway/.env </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">cut</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa"> -f2- </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'"'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">ac</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">907</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">808</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">216</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">505</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"area %s -&gt; "</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$ac</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">model</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">qwen-mid</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">temperature</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:0,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">messages</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:[{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">role</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">user</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">content</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">Which US state does this phone number's area code belong to: (</span><span class="token string variable" style="color:#36acaa">$ac</span><span class="token string" style="color:#e3116c">) 555-0142? Answer with only the state name.</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">}]}"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import sys,json; print(' '.join(json.load(sys.stdin)['choices'][0]['message']['content'].split()))"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">area 907 -&gt; Alaska</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">area 808 -&gt; Hawaii</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">area 216 -&gt; Ohio</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">area 505 -&gt; New Mexico</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Four different area codes each returning the correct state, proving the prompt reached the model unmasked" src="https://development-wec.wiline.com/docs/assets/images/gw3-area-codes-9448b7ab1d15d52e9cafb0d8ad0e5b86.png" width="1574" height="356" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Four for four. The model is reading the digits, so the prompt is reaching it
unmasked. That is the behaviour this post set out to get.</p>
<p>The whitespace collapse in that command is not cosmetic fussiness — <code>qwen-mid</code> is
a reasoning model and prefixes its answers with newlines, inconsistently enough
that <code>.strip()</code> alone leaves ragged output.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-not-just-mask-before-the-model">Why not just mask before the model?<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#why-not-just-mask-before-the-model" class="hash-link" aria-label="Direct link to Why not just mask before the model?" title="Direct link to Why not just mask before the model?" translate="no">​</a></h2>
<p>It is the obvious question, and the obvious answer is wrong. <code>mode: pre_call</code>
masks the prompt before the model ever sees it — which sounds like the safest
possible arrangement.</p>
<p>Change the one line and find out:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">mode</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> pre_call</span><br></div></code></pre></div></div>
<p>Restart, then ask the same question:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-m1</span><span class="token variable" style="color:#36acaa"> LITELLM_MASTER_KEY ~/llm-gateway/.env </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">cut</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa"> -f2- </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'"'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'Content-Type: application/json'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","temperature":0,"max_tokens":60,"messages":[{"role":"user","content":"Which US state does this phone number'</span><span class="token plain">"</span><span class="token string" style="color:#e3116c">'"'</span><span class="token plain">s area code belong to: </span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">907</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">555</span><span class="token plain">-0142? Answer with only the state name.</span><span class="token string" style="color:#e3116c">"}]}' \</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  | python3 -c "</span><span class="token function" style="color:#d73a49">import</span><span class="token plain"> sys,json</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">' '</span><span class="token plain">.join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">json.load</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sys.stdin</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">'choices'</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">'message'</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">'content'</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">.split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">))</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">To determine the state for a given phone number's area code, I would need to know</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">the specific area code of the phone number provided. Please provide the area code</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">or the full phone number so I can identify the corresponding state.</span><br></div></code></pre></div></div>
<p>The exact wording moves around between runs even at <code>temperature: 0</code> — the point
is that every version of it asks you for the number you already sent.</p>
<p><span class="zoomImage__wrap"><img alt="Under pre_call masking the model asks for the phone number it was already given, having received only a placeholder" src="https://development-wec.wiline.com/docs/assets/images/gw3-precall-blind-12b8776adeda133b79cac28a0bb36166.png" width="1564" height="308" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The model is politely asking for the number you just gave it. It received
<code>&lt;PHONE_NUMBER&gt;</code> and there is nothing in a placeholder to reason about. Nothing
failed, nothing errored — you simply got a useless answer, which is the failure
mode that is hardest to notice in production.</p>
<p><code>qwen-mid</code> behaved worse in my runs: the same request timed out twice at 40
seconds with <code>max_tokens: 60</code>, where it answered in well under a second on
unmasked input. I would not claim a mechanism from two samples, but if you are
routing to a reasoning model, time this before trusting it.</p>
<p>So the three placements, same question each time:</p>
<table><thead><tr><th>Configuration</th><th>Model receives</th><th>Result</th></tr></thead><tbody><tr><td><code>mode: pre_call</code></td><td><code>&lt;PHONE_NUMBER&gt;</code></td><td>asks you for the number it was given</td></tr><tr><td><code>mode: logging_only</code>, scope left default</td><td>real digits</td><td>correct answer, returned as <code>&lt;LOCATION&gt;</code></td></tr><tr><td><code>mode: logging_only</code>, <code>presidio_filter_scope: input</code></td><td>real digits</td><td><code>San Francisco</code></td></tr></tbody></table>
<p>Only the third gives you a working application. Put <code>mode</code> back to
<code>logging_only</code> before continuing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-gives-you-and-what-it-does-not">What this gives you, and what it does not<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#what-this-gives-you-and-what-it-does-not" class="hash-link" aria-label="Direct link to What this gives you, and what it does not" title="Direct link to What this gives you, and what it does not" translate="no">​</a></h2>
<p>You now have prompts reaching models intact, with PII masking attached to the
logging path instead of the request path — and one setting away from silently
redacting your users' answers.</p>
<p>One thing this fix does not cover, and it matters: <code>input</code> scope masks the
prompt only. Whatever the model <em>says</em> is logged verbatim — and a model asked
about a customer will repeat that customer's name back. So the PII you removed
from the prompt reappears in the completion. Widening the scope to <code>both</code> closes
that hole and reintroduces the response corruption above; there is no setting
that does both.
<a class="" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/">Part 4</a> measures the leak
and builds the missing piece on <code>async_logging_hook</code>, the documented callback
that runs after the response has been sent.</p>
<p>Being straight about the boundary: the masked copy lands wherever your logging
callbacks send it. The gateway's own <code>LiteLLM_SpendLogs</code> table is not one of
those — <code>messages</code> stayed <code>{}</code> in every configuration I tried, including with
<code>store_prompts_in_spend_logs</code> set both in <code>config.yaml</code> and as an environment
variable. The documentation points at Langfuse and similar destinations, which is
where the next post looks. <strong>Until you have wired one up and seen a masked
payload in it, treat the masking half as unproven in your own deployment.</strong></p>
<p>Two more things worth knowing before this sits in front of real traffic. The
guardrail defaults to <code>fail_on_error: true</code> with <code>unreachable_fallback: fail_closed</code>, so if Presidio goes down your requests fail rather than passing
unmasked — the right default, but plan for it. And every Presidio call adds
latency to the logging path; Part 2's timing method applies here too.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="docker-compose-ps-shows-two-containers-not-four"><code>docker compose ps</code> shows two containers, not four<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#docker-compose-ps-shows-two-containers-not-four" class="hash-link" aria-label="Direct link to docker-compose-ps-shows-two-containers-not-four" title="Direct link to docker-compose-ps-shows-two-containers-not-four" translate="no">​</a></h3>
<p>The compose file was not saved. <code>docker compose config --services</code> reads what is
on disk — if Presidio is missing there, the edit did not land.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="curl-executable-file-not-found-in-path"><code>curl: executable file not found in $PATH</code><a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#curl-executable-file-not-found-in-path" class="hash-link" aria-label="Direct link to curl-executable-file-not-found-in-path" title="Direct link to curl-executable-file-not-found-in-path" translate="no">​</a></h3>
<p>The LiteLLM image has no <code>curl</code>. Probe with the <code>python -c</code> one-liner above.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="name-or-service-not-known-reaching-presidio-analyzer"><code>Name or service not known</code> reaching <code>presidio-analyzer</code><a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#name-or-service-not-known-reaching-presidio-analyzer" class="hash-link" aria-label="Direct link to name-or-service-not-known-reaching-presidio-analyzer" title="Direct link to name-or-service-not-known-reaching-presidio-analyzer" translate="no">​</a></h3>
<p>The two stacks are on different Docker networks. Both sets of services must be in
the same compose project, or share an external network.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="startup-fails-with-validationerror-mode-field-required">Startup fails with <code>ValidationError: mode Field required</code><a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#startup-fails-with-validationerror-mode-field-required" class="hash-link" aria-label="Direct link to startup-fails-with-validationerror-mode-field-required" title="Direct link to startup-fails-with-validationerror-mode-field-required" translate="no">​</a></h3>
<p><code>mode</code> is mandatory on a guardrail. Omitting it exits the proxy on startup —
<code>Application startup failed. Exiting.</code> You cannot express logging-only by leaving
<code>mode</code> out.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-model-answers-correctly-but-the-reply-is-location-or-phone_number">The model answers correctly but the reply is <code>&lt;LOCATION&gt;</code> or <code>&lt;PHONE_NUMBER&gt;</code><a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#the-model-answers-correctly-but-the-reply-is-location-or-phone_number" class="hash-link" aria-label="Direct link to the-model-answers-correctly-but-the-reply-is-location-or-phone_number" title="Direct link to the-model-answers-correctly-but-the-reply-is-location-or-phone_number" translate="no">​</a></h3>
<p><code>presidio_filter_scope</code> is at its <code>both</code> default. Set it to <code>input</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-phone-number-is-not-being-masked">A phone number is not being masked<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#a-phone-number-is-not-being-masked" class="hash-link" aria-label="Direct link to A phone number is not being masked" title="Direct link to A phone number is not being masked" translate="no">​</a></h3>
<p>Check the score. <code>555-0142</code> is not detected at all; <code>(415) 555-0142</code> scores 0.4.
Use <code>presidio_score_thresholds</code> to set a per-entity floor.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="changes-to-configyaml-seem-to-have-no-effect">Changes to <code>config.yaml</code> seem to have no effect<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#changes-to-configyaml-seem-to-have-no-effect" class="hash-link" aria-label="Direct link to changes-to-configyaml-seem-to-have-no-effect" title="Direct link to changes-to-configyaml-seem-to-have-no-effect" translate="no">​</a></h3>
<p>Edit the file, <em>then</em> restart — in that order. And a <code>cd</code> from an earlier step
carries over, so check which directory you are in before running <code>docker compose</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="env-changes-are-ignored"><code>.env</code> changes are ignored<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#env-changes-are-ignored" class="hash-link" aria-label="Direct link to env-changes-are-ignored" title="Direct link to env-changes-are-ignored" translate="no">​</a></h3>
<p><code>docker compose restart</code> does not reload environment. Use
<code>docker compose up -d --force-recreate litellm</code>.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Mask PII at the gateway</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Masking is configured, but you have not yet watched a masked payload land
anywhere. The next post wires a real logging destination to the gateway and looks
for <code>&lt;PERSON&gt;</code> in a trace — closing the loop this one deliberately leaves open —
and then turns to budgets and what the whole arrangement actually costs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/guardrails/pii_masking_v2" target="_blank" rel="noopener noreferrer" class="">PII, PHI Masking — Presidio</a> — the guardrail reference, including <code>presidio_filter_scope</code> and <code>presidio_score_thresholds</code></li>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/logging" target="_blank" rel="noopener noreferrer" class="">Logging, Alerting, Metrics</a> — the destinations a masked copy can go to</li>
<li class=""><a href="https://github.com/data-privacy-stack/presidio" target="_blank" rel="noopener noreferrer" class="">Presidio</a> — the analyzer and anonymizer</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/news/mask-your-logs-not-your-prompts/">Mask your logs, not your prompts</a> — why masking belongs on the storage path</li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 — deploy an LLM gateway on a WEC Instance</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/">Part 2 — complexity routing and what it costs</a></li>
</ul>]]></content:encoded>
            <category>llm</category>
            <category>gateway</category>
            <category>pii</category>
            <category>privacy</category>
            <category>guardrails</category>
            <category>self-hosting</category>
            <category>litellm</category>
            <category>presidio</category>
            <category>wec</category>
        </item>
        <item>
            <title><![CDATA[LiteLLM complexity routing: the right model for each request, and what it costs in latency]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[How LiteLLM's complexity router decides which model answers a request — the seven scoring dimensions, the arithmetic on a real prompt, and a measured comparison of the free keyword scorer against an LLM classifier.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/qwen-209001e8f781e3651b8d7b863343f2dc.png" alt="Qwen"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 5 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->5</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->5<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting an LLM gateway</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">One endpoint, scoped keys</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">2</span><span class="skillTracker__skill" data-state="current">Route work to the right model</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Mask PII at the gateway</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Clean traces, untouched answers</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Prove it holds under load</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1</a> ended on an uncomfortable
number. The same three-word answer cost 3 tokens from a small model and 200 from
a reasoning model — which spent all 200 thinking and returned nothing at all.</p>
<p>Every request your apps send picks a model, and mostly that choice is made once,
hardcoded, and never revisited. This post puts the gateway in charge of it
instead: classify the request, route it to a model sized for the work. Then it
measures what that decision costs, because it is not free and most write-ups
skip that part.</p>
<!-- -->
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="adding-litellms-complexity-router">Adding LiteLLM's complexity router<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#adding-litellms-complexity-router" class="hash-link" aria-label="Direct link to Adding LiteLLM's complexity router" title="Direct link to Adding LiteLLM's complexity router" translate="no">​</a></h2>
<p>The gateway from Part 1 already has three models registered. A router is just
another entry in <code>model_list</code> that maps complexity tiers onto them:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> smart</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">router</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> auto_router/complexity_router</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">complexity_router_config</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">tiers</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">SIMPLE</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">small</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">MEDIUM</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">mid</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">COMPLEX</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">large</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">REASONING</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">large</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">return_raw_model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><br></div></code></pre></div></div>
<p><code>return_raw_model_name: true</code> is the important one for now. Without it the
response reports <code>smart-router</code> and you have no idea which model served you.
With it, the response names the model that actually ran — so every test below
is self-verifying.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart litellm</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="watching-it-route">Watching it route<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#watching-it-route" class="hash-link" aria-label="Direct link to Watching it route" title="Direct link to Watching it route" translate="no">​</a></h2>
<p>You'll send the same request many times with only the prompt changing, so wrap
it once:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function-name function" style="color:#d73a49">ask</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">model</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">smart-router</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">messages</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:[{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">role</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">user</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">content</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string variable" style="color:#36acaa">$1</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">}],</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">max_tokens</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:10}"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> .model</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Then five prompts of increasing difficulty:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ask </span><span class="token string" style="color:#e3116c">"hi"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ask </span><span class="token string" style="color:#e3116c">"what is a vpc"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ask </span><span class="token string" style="color:#e3116c">"write a python function that retries an http call with backoff"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ask </span><span class="token string" style="color:#e3116c">"our two services deadlock under load, walk me through diagnosing it"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ask </span><span class="token string" style="color:#e3116c">"prove that the halting problem is undecidable"</span><br></div></code></pre></div></div>
<p>One line per prompt, in the order sent:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Five prompts routed by the heuristic scorer, four landing on the 3B model" src="https://development-wec.wiline.com/docs/assets/images/gw2-heuristic-route-af0d1b1a3a318287720fa1b18160e760.png" width="1806" height="450" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The code request went up a tier. The two hardest questions did not.</p>
<p>A greeting on the small model is right. A Python request on the mid model is
right. But a distributed-systems deadlock and one of the hardest questions in
computer science both landed on a 3-billion-parameter model.</p>
<p>To understand why, you have to know what the router is actually doing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-a-heuristic-is-and-how-litellm-scores-one">What a heuristic is, and how LiteLLM scores one<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#what-a-heuristic-is-and-how-litellm-scores-one" class="hash-link" aria-label="Direct link to What a heuristic is, and how LiteLLM scores one" title="Direct link to What a heuristic is, and how LiteLLM scores one" translate="no">​</a></h2>
<p>By default this router makes zero API calls. It scores the prompt locally with
pattern matching — that's what "heuristic" means here: a cheap rule of thumb
that approximates a judgement without making it.</p>
<p>It scores seven dimensions, each producing a value between −1 and +1, then
multiplies each by a fixed weight and adds them up:</p>
<table><thead><tr><th>Dimension</th><th>Weight</th><th>Fires on</th></tr></thead><tbody><tr><td><code>codePresence</code></td><td>0.30</td><td><code>function</code>, <code>class</code>, <code>api</code>, <code>schema</code>, …</td></tr><tr><td><code>reasoningMarkers</code></td><td>0.25</td><td>"step by step", "think through", "analyze"</td></tr><tr><td><code>technicalTerms</code></td><td>0.25</td><td>"architecture", "distributed", "encryption"</td></tr><tr><td><code>tokenCount</code></td><td>0.10</td><td><strong>−1.0</strong> under 15 tokens, <strong>+1.0</strong> over 400</td></tr><tr><td><code>simpleIndicators</code></td><td>0.05</td><td>"what is", "define", greetings — scores <strong>−1.0</strong></td></tr><tr><td><code>multiStepPatterns</code></td><td>0.03</td><td>"first… then", numbered steps</td></tr><tr><td><code>questionComplexity</code></td><td>0.02</td><td>more than three question marks</td></tr></tbody></table>
<p>The weighted sum maps to a tier at three boundaries: below <strong>0.15</strong> is SIMPLE,
below <strong>0.35</strong> MEDIUM, below <strong>0.60</strong> COMPLEX, and above that REASONING. Those
values are all documented and all configurable.</p>
<p>Two dimensions can push the score <em>down</em>. A short prompt scores −1.0 on
<code>tokenCount</code>. A prompt containing "what is" scores −1.0 on <code>simpleIndicators</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-arithmetic-on-a-real-prompt">The arithmetic, on a real prompt<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#the-arithmetic-on-a-real-prompt" class="hash-link" aria-label="Direct link to The arithmetic, on a real prompt" title="Direct link to The arithmetic, on a real prompt" translate="no">​</a></h2>
<p>The router records its own working. Every routed request writes a decision into
the spend log:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/spend/logs </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.[].metadata.routing_decision | select(.) | "\(.tier)  \(.score)  -&gt; \(.routed_model)  \(.signals)"'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">head</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-5</span><br></div></code></pre></div></div>
<p>Newest first, so this reads bottom-up against the order the prompts were sent:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">SIMPLE  -0.1  -&gt; qwen-small  ["short (11 tokens)"]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SIMPLE  0  -&gt; qwen-small  null</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MEDIUM  0.3  -&gt; qwen-mid  ["code (function, python)"]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SIMPLE  -0.15000000000000002  -&gt; qwen-small  ["short (3 tokens)","simple (what is)"]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SIMPLE  -0.15000000000000002  -&gt; qwen-small  ["short (0 tokens)","simple (hi)"]</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Routing decisions showing tier, score and the signals behind each" src="https://development-wec.wiline.com/docs/assets/images/gw2-decisions-c9f72192a937dfe8928c7a438f555144.png" width="1788" height="274" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Not a black box — the router reports the score and the signals it fired.</p>
<p>Take the last one. <code>prove that the halting problem is undecidable</code>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">tokenCount        -1.0 × 0.10 = -0.10    11 tokens, under the 15 threshold</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">codePresence       0.0 × 0.30 =  0.00    no code keywords</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">reasoningMarkers   0.0 × 0.25 =  0.00    "prove" is not in the marker list</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">technicalTerms     0.0 × 0.25 =  0.00</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">simpleIndicators   0.0 × 0.05 =  0.00</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">multiStepPatterns  0.0 × 0.03 =  0.00</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">questionComplexity 0.0 × 0.02 =  0.00</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                ------</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                 -0.10   below 0.15 → SIMPLE → 3B model</span><br></div></code></pre></div></div>
<p>Every term is zero except a penalty for being short. The question gets routed
down <em>because it is brief</em>.</p>
<p>Now the deadlock prompt, which is worse: it scored <strong>0.00 with no signals at
all</strong>. Not a low score — nothing matched. "Our two services deadlock under load,
walk me through diagnosing it" contains no keyword the scorer recognises, so it
falls to SIMPLE by default.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can read a routing decision — tier, score, and the signals that produced it
— and reproduce the arithmetic by hand from the dimension weights.</p></div></div>
<p>The router isn't malfunctioning. It is doing exactly what its rules say. The
rules just have no way to see difficulty that isn't spelled out in vocabulary
it knows.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Where this bites</div><div class="admonitionContent_BuS1"><p>The failure mode is systematic, not random: <strong>short prompts that need deep
thinking get routed down</strong>. Those are also the prompts where a wrong model is
most obvious to the user.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-keyword-never-matches-its-own-plural">A keyword never matches its own plural<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#a-keyword-never-matches-its-own-plural" class="hash-link" aria-label="Direct link to A keyword never matches its own plural" title="Direct link to A keyword never matches its own plural" translate="no">​</a></h3>
<p>Single-word keywords are matched on word boundaries, so <code>endpoint</code> does not match
<code>endpoints</code>. Every single-word keyword in the default lists is singular, and none
of them match a plural.</p>
<p>Whether that changes anything depends on how close the score already sits to a
boundary, because the dimensions are stepped rather than linear — <code>technicalTerms</code>
scores 0.5 at two matches and 1.0 at four, so losing one match often changes
nothing. Sometimes it changes the model:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Review our api endpoint  for authentication and authorization problems → COMPLEX +0.425</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Review our api endpoints for authentication and authorization problems → MEDIUM  +0.275</span><br></div></code></pre></div></div>
<p>One letter, one tier. The direction is always the same: a plural scores lower or
equal, never higher, so the drift is toward the cheaper model.</p>
<p>Plurals you care about can be appended to the technical list with
<code>custom_technical_keywords</code>. There is no equivalent for code keywords — the only
lever is <code>code_keywords</code>, which replaces the built-in list rather than extending
it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="asking-a-model-instead">Asking a model instead<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#asking-a-model-instead" class="hash-link" aria-label="Direct link to Asking a model instead" title="Direct link to Asking a model instead" translate="no">​</a></h2>
<p>The alternative is to spend a model call on the decision. Four lines:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">classifier_type</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> llm</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">classifier_llm_config</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">small</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">timeout_ms</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3000</span><br></div></code></pre></div></div>
<p>Now the router sends the prompt to <code>qwen-small</code> with a rubric and a schema that
forces back exactly one of <code>SIMPLE</code>, <code>MEDIUM</code>, <code>COMPLEX</code>, <code>REASONING</code>, and routes
on the answer. The classifier here is the <em>cheapest</em> model we have — the same 3B
that was wrongly answering the hard questions a moment ago. It turns out to be a
better judge of difficulty than it is an answerer of it.</p>
<p>Restart, then re-run the identical five prompts:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-122B</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same five prompts under the LLM classifier, with the hard ones now on the 122B model" src="https://development-wec.wiline.com/docs/assets/images/gw2-llm-route-9a7f2aa9f50bca506a1786ae36cf8c3b.png" width="1262" height="370" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> Both failures corrected — and two prompts moved up that arguably shouldn't have.</p>
<table><thead><tr><th>Prompt</th><th>Heuristic</th><th>LLM classifier</th></tr></thead><tbody><tr><td><code>hi</code></td><td>3B</td><td>3B</td></tr><tr><td><code>what is a vpc</code></td><td>3B</td><td>9B</td></tr><tr><td><code>python function … backoff</code></td><td>9B</td><td>122B</td></tr><tr><td><code>deadlock under load</code></td><td>3B ✗</td><td>122B ✓</td></tr><tr><td><code>halting problem</code></td><td>3B ✗</td><td>122B ✓</td></tr></tbody></table>
<p>The two broken cases are fixed. But read rows 2 and 3 again — everything moved
up. "What is a vpc" is a factual lookup a 3B model answers perfectly well, and
it now runs on the 9B. You stopped under-serving hard prompts and started
over-serving easy ones. Whether that trade is worth it depends on your traffic
mix, and you should measure yours rather than trust this table.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-costs">What it costs<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#what-it-costs" class="hash-link" aria-label="Direct link to What it costs" title="Direct link to What it costs" translate="no">​</a></h2>
<p>This is the part that gets left out. Add a twin that skips the router, then time
both:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function-name function" style="color:#d73a49">direct</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">model</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">qwen-small</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">messages</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:[{</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">role</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">user</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">,</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">content</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string variable" style="color:#36acaa">$1</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">}],</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">max_tokens</span><span class="token string entity" style="color:#36acaa">\"</span><span class="token string" style="color:#e3116c">:10}"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> .model</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">time</span><span class="token plain"> ask </span><span class="token string" style="color:#e3116c">"hi"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">time</span><span class="token plain"> ask </span><span class="token string" style="color:#e3116c">"hi"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">time</span><span class="token plain"> direct </span><span class="token string" style="color:#e3116c">"hi"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct     real  0m2.474s     ← first call after restart</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct     real  0m1.041s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">qwen-small              real  0m0.529s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="time output comparing a routed call against a direct call" src="https://development-wec.wiline.com/docs/assets/images/gw2-timing-ea97fdf0902b60a49ea16f3951a173c6.png" width="1802" height="722" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Same prompt, same model answers it, twice the wall clock.</p>
<p>The same 3B model produced both answers. The only difference is that one request
was classified first. Now decompose it — the spend log times each call
separately:</p>
<table><thead><tr><th>Wall clock</th><th>Classifier call</th><th>Serving call</th></tr></thead><tbody><tr><td>2.474 s (cold)</td><td>1449 ms</td><td>806 ms</td></tr><tr><td>1.041 s</td><td>549 ms</td><td>456 ms</td></tr><tr><td>0.529 s (direct)</td><td>—</td><td>495 ms</td></tr></tbody></table>
<p>The arithmetic closes: 549 + 456 = 1005 ms against 1.041 s measured at the shell.</p>
<p>Three things fall out:</p>
<p><strong>The overhead is not a constant.</strong> Across the day's samples the classifier call
ranged from 476 ms to 1449 ms, and the first request after a restart cost 1449 ms
on its own. It is a full inference call and inherits whatever the backend is
doing. Any single number you quote for it is the number you happened to catch.</p>
<p><strong>Token cost is fixed and larger than you'd guess.</strong> Each classification sent
<strong>281–293 input tokens</strong> — the rubric — and returned about 10. Routing <code>hi</code>, a
one-token prompt, costs ~281 tokens of classification before anything answers.</p>
<p><strong>The serving call slows down too.</strong> 456–806 ms when routed, against 495 ms
direct for the identical request. The classifier appears to leave the backend
busy for the request queued behind it. You would never see this from wall clock
alone.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Measure the parts, not the total</div><div class="admonitionContent_BuS1"><p>Paired wall-clock timing tells you <em>that</em> it got slower, never <em>where</em>. Decompose
into the classifier call and the serving call before you quote a figure — the
first honest number here was almost double the one a single <code>time</code> run suggested.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trap-worth-knowing-about">The trap worth knowing about<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#the-trap-worth-knowing-about" class="hash-link" aria-label="Direct link to The trap worth knowing about" title="Direct link to The trap worth knowing about" translate="no">​</a></h2>
<p>If the classification call times out, returns the wrong shape, or comes back
empty, the router falls back to <strong>the heuristic scorer</strong> — the thing whose
failures you just paid half a second to avoid. Silently.</p>
<p>The official docs name the way out: set <code>classifier_fallback: default_model</code> and
a timeout routes to a model you chose deliberately, rather than to the scorer
that sends hard questions to a 3B model.</p>
<p>Also worth checking rather than assuming: the docs give <code>timeout_ms</code> a default of
2000, while the package in our container defaults to 3000. Read the version you
actually installed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="so-which-one">So which one<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#so-which-one" class="hash-link" aria-label="Direct link to So which one" title="Direct link to So which one" translate="no">​</a></h2>
<table><thead><tr><th></th><th>Heuristic</th><th>LLM classifier</th></tr></thead><tbody><tr><td>Latency added</td><td>sub-millisecond</td><td>476–1449 ms</td></tr><tr><td>Extra API calls</td><td>none</td><td>one per request</td></tr><tr><td>Extra input tokens</td><td>none</td><td>~280 per request</td></tr><tr><td>Hard-but-short prompts</td><td>routed down</td><td>routed correctly</td></tr><tr><td>Easy prompts</td><td>routed correctly</td><td>routed up</td></tr><tr><td>Fails by</td><td>being confidently wrong</td><td>timing out, then being confidently wrong</td></tr></tbody></table>
<p>The heuristic is the right default for high-volume traffic that looks alike, and
you can improve it a lot with <code>custom_technical_keywords</code> for your own domain
vocabulary. The LLM classifier earns its cost when prompts are varied, when
getting the model wrong is expensive, and when half a second doesn't matter —
batch work, agents, anything already taking seconds.</p>
<p>What neither of them is, is free.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-response-says-smart-router-instead-of-a-model-name">The response says <code>smart-router</code> instead of a model name<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#the-response-says-smart-router-instead-of-a-model-name" class="hash-link" aria-label="Direct link to the-response-says-smart-router-instead-of-a-model-name" title="Direct link to the-response-says-smart-router-instead-of-a-model-name" translate="no">​</a></h3>
<p><code>return_raw_model_name</code> isn't set. Without it the router echoes the alias you
asked for and you can't tell what served the request.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="every-prompt-routes-to-the-same-tier">Every prompt routes to the same tier<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#every-prompt-routes-to-the-same-tier" class="hash-link" aria-label="Direct link to Every prompt routes to the same tier" title="Direct link to Every prompt routes to the same tier" translate="no">​</a></h3>
<p>Check the <code>signals</code> field in the routing decision. If it's <code>null</code>, no dimension
matched at all and the prompt scored 0.00, which lands in SIMPLE. That's a sign
your traffic doesn't use the vocabulary the default keyword lists expect.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="routing-decisions-arent-in-the-spend-log">Routing decisions aren't in the spend log<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#routing-decisions-arent-in-the-spend-log" class="hash-link" aria-label="Direct link to Routing decisions aren't in the spend log" title="Direct link to Routing decisions aren't in the spend log" translate="no">​</a></h3>
<p>They're on the routed request, under <code>metadata.routing_decision</code>, not on the
classifier call. Filter with <code>map(select(.))</code> as above — half the rows are the
classifier calls themselves and carry no decision.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-first-request-after-a-restart-is-much-slower">The first request after a restart is much slower<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#the-first-request-after-a-restart-is-much-slower" class="hash-link" aria-label="Direct link to The first request after a restart is much slower" title="Direct link to The first request after a restart is much slower" translate="no">​</a></h3>
<p>Cold start. The first classification here took 1449 ms against roughly 550 ms
once warm. Discard the first sample when timing.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Route work to the right model</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The gateway now decides which model runs, and you know what that decision costs
in latency and tokens. It still forwards every prompt verbatim to whichever model
wins — including the ones containing customer names, emails and API keys.</p>
<p>The next post puts a filter in that path.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.litellm.ai/docs/proxy/auto_routing" target="_blank" rel="noopener noreferrer" class="">Auto Routing — configuration reference</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/">Part 1 — deploy an LLM gateway on a WEC Instance</a></li>
</ul>]]></content:encoded>
            <category>llm</category>
            <category>gateway</category>
            <category>routing</category>
            <category>cost</category>
            <category>self-hosting</category>
            <category>litellm</category>
            <category>wec</category>
        </item>
        <item>
            <title><![CDATA[One endpoint, many models: deploy an LLM gateway on a WEC Instance]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Every app on a box holding the same API key is a problem waiting to happen. A gateway fixes that — one endpoint, scoped keys per app, per-key budgets, and a log of who spent what. Deployed for real on a host already running five other stacks, including the parts that surprised us.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/litellm-wordmark-86d6f0432720b27534e05bc579dfc29c.png" alt="LiteLLM"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 5 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->5</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->5<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting an LLM gateway</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">One endpoint, scoped keys</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/litellm-complexity-routing/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Route work to the right model</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-pii-llm-gateway-presidio/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Mask PII at the gateway</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mask-logs-not-responses-langfuse-gateway/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Clean traces, untouched answers</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/load-test-llm-gateway-blocking-callback/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Prove it holds under load</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Here's how it usually goes. One app needs a model, so you paste the API key into
its <code>.env</code>. Then a second app needs one. Then a script. Six months later the same
key is in five places, nobody remembers which of them is still running, and you
can't rotate it without breaking something you'll only find out about when it
breaks.</p>
<p>A gateway is the boring fix. One endpoint in front of every model, one place that
holds the real credential, and a scoped key per app that you can revoke on its own.
This post deploys one on a WEC Instance and points it at the WEC Inference API.</p>
<!-- -->
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-were-putting-on-the-box">What we're putting on the box<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#what-were-putting-on-the-box" class="hash-link" aria-label="Direct link to What we're putting on the box" title="Direct link to What we're putting on the box" translate="no">​</a></h2>
<p>The host already runs five other stacks, which matters — this is the normal case,
not a clean VM. Before adding anything, check what's listening and what's free:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--format</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.Names}}\t{{.Ports}}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">':(4000|5432|5433)\b'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-postgres-1   127.0.0.1:5433-&gt;5432/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-clickhouse-1 127.0.0.1:8123-&gt;8123/tcp, 127.0.0.1:9000-&gt;9000/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">openclaw-caddy-1      100.87.239.229:80-&gt;80/tcp, 100.87.239.229:443-&gt;443/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">LISTEN 0  244   127.0.0.1:5432   users:(("postgres",pid=892))</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">LISTEN 0  4096  127.0.0.1:5433   users:(("docker-proxy"))</span><br></div></code></pre></div></div>
<p>Port 4000 is free. Postgres 5432 belongs to the host and 5433 to Langfuse, so the
gateway's database gets neither — it won't publish a port at all.</p>
<p>Then confirm the backend answers before putting anything in front of it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/models </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.data[].id'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">whisper-large-v3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wiline-coding</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">kokoro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Llama3.1-8B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">bge-m3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen3.5-9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wiline-auto</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wiline-cost</span><br></div></code></pre></div></div>
<p>Thirteen models on one key. That's the thing we're about to stop handing out.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-stack">The stack<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#the-stack" class="hash-link" aria-label="Direct link to The stack" title="Direct link to The stack" translate="no">​</a></h2>
<p>Two containers: the gateway and a Postgres for its own state. Make a directory and
write the compose file:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/llm-gateway </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/llm-gateway</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">litellm</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ghcr.io/berriai/litellm</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">main</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stable</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">container_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> llm</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">ports</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"127.0.0.1:4000:4000"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/app/config.yaml</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">command</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"--config"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"/app/config.yaml"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"--port"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"4000"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">env_file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">depends_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">condition</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> service_healthy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">17</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">container_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> llm</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">gateway</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">db</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">user</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"999:999"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_USER</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> llmproxy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_PASSWORD</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">POSTGRES_PASSWORD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_DB</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> litellm</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> gateway</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/var/lib/postgresql/data</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">healthcheck</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">test</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"CMD-SHELL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"pg_isready -U llmproxy -d litellm"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">interval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> 5s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">timeout</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> 5s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">retries</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">10</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  gateway</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">db</span><span class="token punctuation" style="color:#393A34">:</span><br></div></code></pre></div></div>
<p>Four of those lines are doing real work:</p>
<p><code>127.0.0.1:4000:4000</code> keeps the gateway off the public internet. It holds every
model credential you own, so publishing it to <code>0.0.0.0</code> would put all of them
behind a single shared password. <a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Part 1 of the hardening
series</a> is the long version of why
that's a bad trade.</p>
<p>The database has no <code>ports:</code> at all. Nothing outside the compose network needs to
reach it, and 5432 was taken anyway.</p>
<p><code>user: "999:999"</code> runs Postgres as its built-in non-root user from the first boot.
<a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/">Part 2</a> covers what that buys you
and how to find the right UID for an image instead of guessing.</p>
<p><code>condition: service_healthy</code> matters more than it looks. The gateway runs database
migrations on startup — without it, it races Postgres and crash-loops.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="telling-it-about-the-models">Telling it about the models<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#telling-it-about-the-models" class="hash-link" aria-label="Direct link to Telling it about the models" title="Direct link to Telling it about the models" translate="no">​</a></h2>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">config.yaml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">model_list</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">small</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> openai/Qwen2.5</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">3B</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">mid</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> openai/Qwen3.5</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> qwen</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">large</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> openai/Qwen3.5</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">model_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> embeddings</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">litellm_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> openai/bge</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">m3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_base</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">api_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">general_settings</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">master_key</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/LITELLM_MASTER_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">database_url</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> os.environ/DATABASE_URL</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">store_model_in_db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">litellm_settings</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">drop_params</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><br></div></code></pre></div></div>
<p><code>model_name</code> is the alias your apps ask for. <code>model:</code> is what the gateway actually
calls. That indirection is most of the value — swap <code>qwen-mid</code> to point somewhere
else next month and not one client changes.</p>
<p>The <code>openai/</code> prefix says "speak the OpenAI protocol to a custom <code>api_base</code>." The
WEC Inference API is OpenAI-compatible, so that's all it takes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="secrets">Secrets<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#secrets" class="hash-link" aria-label="Direct link to Secrets" title="Direct link to Secrets" translate="no">​</a></h2>
<p>Generate the passwords rather than inventing them:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">PGPW</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable" style="color:#36acaa">openssl rand </span><span class="token variable parameter variable" style="color:#36acaa">-hex</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">16</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">MK</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"sk-</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-hex</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">20</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">EOF</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">POSTGRES_PASSWORD=</span><span class="token string variable" style="color:#36acaa">${PGPW}</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">DATABASE_URL=postgresql://llmproxy:</span><span class="token string variable" style="color:#36acaa">${PGPW}</span><span class="token string" style="color:#e3116c">@db:5432/litellm</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">LITELLM_MASTER_KEY=</span><span class="token string variable" style="color:#36acaa">${MK}</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">WEC_API_KEY=your-wec-key-here</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">chmod</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">600</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<p>Then put your real WEC key in place of <code>your-wec-key-here</code>. The master key is the
gateway's root credential — it can create keys, delete keys and reach every model,
so it goes in your password manager and nowhere else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="bringing-it-up">Bringing it up<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#bringing-it-up" class="hash-link" aria-label="Direct link to Bringing it up" title="Direct link to Bringing it up" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Image ghcr.io/berriai/litellm:main-stable Pulled          58.2s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Network llm-gateway_default               Created          0.1s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Volume llm-gateway_gateway-db             Created          0.1s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Container llm-gateway-db                  Healthy          8.8s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Container llm-gateway                     Created          0.2s</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose up pulling the image and creating both containers" src="https://development-wec.wiline.com/docs/assets/images/gw-compose-up-e741e826f043d86aa7f1a7214f71fbcb.png" width="1066" height="298" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> One pull, two containers, a network and a volume.</p>
<p><code>Created</code> rather than <code>Started</code> on that last line is normal for the compose output,
but check anyway — and check what the image cost you in disk while you're there:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">df</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-h</span><span class="token plain"> /</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NAME             IMAGE                                 SERVICE   STATUS                   PORTS</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">llm-gateway      ghcr.io/berriai/litellm:main-stable   litellm   Up 3 minutes             127.0.0.1:4000-&gt;4000/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">llm-gateway-db   postgres:17                           db        Up 3 minutes (healthy)   5432/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Filesystem      Size  Used Avail Use% Mounted on</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/dev/vda1        58G   54G  4.2G  93% /</span><br></div></code></pre></div></div>
<p>Note the database's <code>PORTS</code> column — <code>5432/tcp</code> with no host binding in front of
it. The gateway image is 1.16GB, which on a box already running five stacks is not
nothing. Check you have the room before you start, not after.</p>
<p>Two lines in the startup log are worth reading, because both look worse than they are:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose logs litellm </span><span class="token parameter variable" style="color:#36acaa">--tail</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">40</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">✅ Migration diff applied successfully</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     Application startup complete.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INFO:     Uvicorn running on http://0.0.0.0:4000 (Press CTRL+C to quit)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">register_model: model=openai/Qwen2.5-3B-Instruct not in built-in cost map and no</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">prefix/region variant matched; cache cost fields will default to 0.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Container status, disk usage and the startup log with migrations and the cost-map warnings" src="https://development-wec.wiline.com/docs/assets/images/gw-ps-logs-ed50299979281a4fb3dfd6ec9c89d189.png" width="2496" height="1408" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Migrations applied, application startup complete, and one cost-map
warning per model.</p>
<p><code>0.0.0.0:4000</code> is the bind <em>inside</em> the container. The host publish is still
<code>127.0.0.1</code>, which is what actually gates access. And the cost-map warning is
narrower than it reads — it's about cache pricing specifically. Regular token
pricing still happens, which turns out to matter later.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-first-call">The first call<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#the-first-call" class="hash-link" aria-label="Direct link to The first call" title="Direct link to The first call" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> ./.env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">set</span><span class="token plain"> +a</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Reply with exactly: gateway works"}],"max_tokens":20}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"finish_reason": "length",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">"message": {</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "reasoning_content": "Thinking Process:\n\n1.  **Analyze the Request:**\n    *   Input: \"",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "content": null</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">"usage": {"completion_tokens": 20, "prompt_tokens": 16, "total_tokens": 36}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Raw response with finish_reason length, reasoning_content populated and content null" src="https://development-wec.wiline.com/docs/assets/images/gw-reasoning-null-2d60fce2efcb8e175da27120082ac585.png" width="2480" height="1094" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> A successful call with nothing in it.</p>
<p><code>content: null</code>. The call worked — it routed, it hit the backend, it counted tokens
— but there's no answer in it.</p>
<p><code>Qwen3.5-9B</code> is a reasoning model. It spent all twenty tokens thinking and had none
left to speak with. The obvious move is to give it more room:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"Reply with exactly: gateway works"}],"max_tokens":200}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.choices[0].message.content, .usage'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">null</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "completion_tokens": 200,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "prompt_tokens": 16,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "total_tokens": 216</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>Two hundred tokens, still nothing. And this is the part worth pausing on: an earlier
run of that exact command <em>did</em> answer, at 192 tokens. Same model, same prompt,
different amount of thinking. There's no <code>max_tokens</code> you can set that reliably
buys you an answer, because the reasoning length isn't fixed.</p>
<p>Now the same prompt through the small model, at the original twenty tokens:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","messages":[{"role":"user","content":"Reply with exactly: gateway works"}],"max_tokens":20}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.choices[0].message.content, .usage'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"gateway works"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "completion_tokens": 3,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "prompt_tokens": 35,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "total_tokens": 38</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Reasoning model burning 200 completion tokens and returning null, small model answering in 3" src="https://development-wec.wiline.com/docs/assets/images/gw-token-contrast-922bab5aa94a6417e02dbc0d6fe0c874.png" width="1854" height="608" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Two hundred tokens and no answer, against three tokens and the answer.</p>
<p>Nothing is broken here. The reasoning model is doing exactly what it's built to do,
on a question that didn't need it. The fix isn't a bigger token budget — it's not
sending trivial work to that model in the first place. Which is the entire argument
for routing, and the subject of the next post.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can put a gateway in front of several models, give each one an alias your apps
call instead of a provider model ID, and read a response well enough to tell a
reasoning model from a plain one.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proving-the-door-is-locked">Proving the door is locked<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#proving-the-door-is-locked" class="hash-link" aria-label="Direct link to Proving the door is locked" title="Direct link to Proving the door is locked" translate="no">​</a></h2>
<p>A gateway that holds every credential you own should refuse anyone without a key.
Worth checking rather than assuming:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"no key:     %{http_code}</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"hi"}]}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"wrong key:  %{http_code}</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer sk-not-a-real-key"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"hi"}]}'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"master key: %{http_code}</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-mid","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">no key:     401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wrong key:  401</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">master key: 200</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Three curl calls returning 401, 401 and 200" src="https://development-wec.wiline.com/docs/assets/images/gw-auth-401-a9d9d89ff8432a6ec7a29486800ea82c.png" width="1854" height="302" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> Anonymous and forged both rejected, the real key through.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-admin-ui">The admin UI<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#the-admin-ui" class="hash-link" aria-label="Direct link to The admin UI" title="Direct link to The admin UI" translate="no">​</a></h2>
<p>The gateway ships a web interface, and it's bound to localhost — which is the
point, so reach it over SSH rather than opening a port:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">ssh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">4000</span><span class="token plain">:127.0.0.1:4000 ubuntu@your-wec-instance</span><br></div></code></pre></div></div>
<p>Leave that running and open <code>http://localhost:4000/ui</code>. Username <code>admin</code>, password
is the master key from your <code>.env</code>.</p>
<p><span class="zoomImage__wrap"><img alt="The gateway&amp;#39;s login screen, asking for admin plus the master key" src="https://development-wec.wiline.com/docs/assets/images/gw-ui-login-94d370f9590bd022684fb80ccabb2aff.png" width="2508" height="1458" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The login page states its own default credentials, which is a good
reminder that the master key is the only thing standing there.</p>
<p>Click <strong>Models + Endpoints</strong>:</p>
<p><span class="zoomImage__wrap"><img alt="Model management listing qwen-small, qwen-mid, qwen-large and embeddings, all showing zero cost" src="https://development-wec.wiline.com/docs/assets/images/gw-ui-models-4372c39b1bfb45a2d61782e145f1c5e2.png" width="2510" height="1464" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> All four aliases registered, each mapped to its <code>openai/...</code> target.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="keys-that-cant-do-everything">Keys that can't do everything<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#keys-that-cant-do-everything" class="hash-link" aria-label="Direct link to Keys that can't do everything" title="Direct link to Keys that can't do everything" translate="no">​</a></h2>
<p>The master key can reach every model and create more keys. No application should
ever hold it. Instead, issue a virtual key scoped to what that app actually needs.</p>
<p><strong>Virtual Keys</strong> → <strong>+ Create New Key</strong>:</p>
<table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>Owned By</td><td>You</td></tr><tr><td>Team</td><td>leave empty</td></tr><tr><td>Key Name</td><td><code>demo-app</code></td></tr><tr><td>Models</td><td><code>qwen-small</code></td></tr><tr><td>Max Budget (USD)</td><td><code>0.10</code></td></tr><tr><td>Reset Budget</td><td>daily</td></tr></tbody></table>
<p>Two things about this form. <strong>Max Budget</strong> lives under <strong>Optional Settings</strong>, which
starts collapsed — easy to miss and then wonder where the budget field went. And
leaving <strong>Models</strong> empty means <em>all models</em>, the opposite of what you want here.</p>
<p><span class="zoomImage__wrap"><img alt="The key creation form with qwen-small selected and a ten cent daily budget" src="https://development-wec.wiline.com/docs/assets/images/gw-key-create-f440427f7d6f55ad01482ed469ff3624.png" width="2510" height="1460" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> Scoped to one model, capped at ten cents a day.</p>
<p><span class="zoomImage__wrap"><img alt="The Save your Key dialog, warning that the key cannot be viewed again" src="https://development-wec.wiline.com/docs/assets/images/gw-key-saved-e13c0abfcd4b4c0bfefa791764eaae50.png" width="2560" height="1476" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> The dialog says it plainly. Believe it.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Copy the key now</div><div class="admonitionContent_BuS1"><p>The generated key is shown once and never again. Close this without copying and
your only option is to delete the key and create another.</p></div></div>
<p>Now the part that justifies the whole exercise. Same key, two models:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$VK</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-small","messages":[{"role":"user","content":"say hi"}],"max_tokens":20}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Hi there! How can I assist you today?</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$VK</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"qwen-large","messages":[{"role":"user","content":"say hi"}],"max_tokens":20}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "error": {</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    "message": "key not allowed to access model. This key can only access models=['qwen-small']. Tried to access qwen-large",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    "type": "key_model_access_denied",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    "param": "model",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    "code": "403"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  }</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same virtual key answering on qwen-small and being refused with a 403 on qwen-large" src="https://development-wec.wiline.com/docs/assets/images/gw-key-403-ff8ff7ee956b8b081dd67c4d3834ac10.png" width="1858" height="508" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 10.</strong> One credential, one model. Leak it and the blast radius is that one line.</p>
<p>Compare that to the situation we started in: a key in five <code>.env</code> files, with access
to all thirteen models and no way to tell which app is using it.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can issue a scoped credential per application, restrict it to specific models
with a spend cap, and revoke it on its own without touching anything else.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-gateway-recorded">What the gateway recorded<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#what-the-gateway-recorded" class="hash-link" aria-label="Direct link to What the gateway recorded" title="Direct link to What the gateway recorded" translate="no">​</a></h2>
<p>Every call lands in a spend log:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://127.0.0.1:4000/spend/logs </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'[.[] | select(.spend &gt; 0)] | .[-3:] | .[] | {model, spend, prompt_tokens, completion_tokens}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "model": "openai/Qwen3.5-9B",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "spend": 8.520000000000001e-05,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "prompt_tokens": 16,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "completion_tokens": 20</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "model": "openai/Qwen3.5-9B",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "spend": 2.62e-05,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "prompt_tokens": 11,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "completion_tokens": 5</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Spend log rows showing token counts and a dollar figure per call" src="https://development-wec.wiline.com/docs/assets/images/gw-spend-logs-dfdc834679299ecd1fcfcc774e83b5b3.png" width="1856" height="684" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 11.</strong> Tokens counted per call, with a price attached to each.</p>
<p>That <code>select(.spend &gt; 0)</code> isn't cosmetic. Failed calls are logged too, at zero
tokens and zero spend — the 401s from earlier are all sitting in there with
<code>status: "failure"</code>. Useful on its own: the spend log doubles as a record of
someone trying keys that don't work.</p>
<p>Those dollar figures are real. They match the published per-model pricing for the
API this gateway is calling — $0.70 per million input tokens and $3.70 per million
output for <code>Qwen3.5-9B</code>, the same rates you see listed against each model before
you pick one. So the spend log is a genuine record of what every call cost, broken
down per key and per model, from the first request.</p>
<p>Two more things in the full log entry, both useful:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token property" style="color:#36acaa">"litellm_overhead_time_ms"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">36.189</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token property" style="color:#36acaa">"messages"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token property" style="color:#36acaa">"response"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>The gateway measures its own overhead and reports it per request — 36ms here, which
is a real number you can hold it to. And prompts and responses aren't stored by
default: the log knows a call happened and what it cost, not what was said.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-gateway-container-starts-and-immediately-exits">The gateway container starts and immediately exits<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#the-gateway-container-starts-and-immediately-exits" class="hash-link" aria-label="Direct link to The gateway container starts and immediately exits" title="Direct link to The gateway container starts and immediately exits" translate="no">​</a></h3>
<p>Almost always the database. Check <code>docker compose logs litellm</code> for migration
errors — if the gateway came up before Postgres was ready, the <code>depends_on</code>
condition is missing or the healthcheck isn't passing.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="content-comes-back-null-with-finish_reason-length"><code>content</code> comes back <code>null</code> with <code>finish_reason: "length"</code><a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#content-comes-back-null-with-finish_reason-length" class="hash-link" aria-label="Direct link to content-comes-back-null-with-finish_reason-length" title="Direct link to content-comes-back-null-with-finish_reason-length" translate="no">​</a></h3>
<p>The model is a reasoning model and it used your whole token budget thinking. Raising
<code>max_tokens</code> helps but doesn't guarantee anything — the same prompt at 200 tokens
answered on one run and returned <code>null</code> on the next. For work that doesn't need
reasoning, send it to a model that doesn't do any.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-admin-ui-wont-load">The admin UI won't load<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#the-admin-ui-wont-load" class="hash-link" aria-label="Direct link to The admin UI won't load" title="Direct link to The admin UI won't load" translate="no">​</a></h3>
<p>It's bound to <code>127.0.0.1</code>, so it's unreachable from anywhere but the box itself.
Use an SSH tunnel. If the tunnel is up and the page still won't load, confirm the
port in <code>docker compose ps</code> matches the one you forwarded.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-shell-chain-quietly-did-nothing">A shell chain quietly did nothing<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#a-shell-chain-quietly-did-nothing" class="hash-link" aria-label="Direct link to A shell chain quietly did nothing" title="Direct link to A shell chain quietly did nothing" translate="no">​</a></h3>
<p>Chains joined with <code>&amp;&amp;</code> stop at the first command that fails, and a failed <code>grep</code>
counts as a failure. Sourcing an env file whose variable name you guessed wrong
leaves a placeholder in place and everything looks fine until an auth error three
steps later. Check for placeholders explicitly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'your-wec-key-here'</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="deleting-a-virtual-key">Deleting a virtual key<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#deleting-a-virtual-key" class="hash-link" aria-label="Direct link to Deleting a virtual key" title="Direct link to Deleting a virtual key" translate="no">​</a></h3>
<p>From the terminal, by alias:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://127.0.0.1:4000/key/delete </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$LITELLM_MASTER_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"key_aliases":["demo-app"]}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq</span><br></div></code></pre></div></div>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>One endpoint, scoped keys</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You now have one endpoint, a credential per app, and a log of every call. The
obvious question is the one Figure 4 raised: if the small model answers in 3 tokens
what the reasoning model spends 200 on without answering at all, why is anything
routing to the expensive one by default?</p>
<p>The next post puts the gateway in charge of that decision, and measures what asking
it costs.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/deploy-llm-gateway-wec-instance/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.docker.com/reference/compose-file/services/#depends_on" target="_blank" rel="noopener noreferrer" class="">Docker Compose — service dependencies and healthchecks</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Harden Docker networks for multi-agent hosts</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/">Root by default: hardening container privilege</a></li>
</ul>]]></content:encoded>
            <category>llm</category>
            <category>gateway</category>
            <category>docker</category>
            <category>self-hosting</category>
            <category>api</category>
            <category>litellm</category>
            <category>wec</category>
        </item>
        <item>
            <title><![CDATA[Root by default: hardening container privilege on a self-hosted AI stack]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/</guid>
            <pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Two of the six containers behind a live Langfuse deployment were running as root, for no reason anyone chose. Fixing it took one line each — and broke a service that had nothing to do with the fix. A real conversion, a real coordination failure, and how to catch both.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Lock down Docker networks</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">2</span><span class="skillTracker__skill" data-state="current">Run containers as non-root</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a class="" href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/">Part 1</a> of this series audited a live
multi-agent box and found every network-exposure question worth asking. One line from
that audit didn't get followed up: <em>"the Postgres, ClickHouse and Redis behind Langfuse
were published to <code>127.0.0.1</code> instead of the world — someone made a good decision
there. Hold that thought."</em></p>
<p>Here's the other half of that thought: getting the network right says nothing about
what happens <em>after</em> someone's already inside a container. If the process running in
there is root, a compromise starts with the keys to the whole filesystem. So we checked
— on the same box, the same Langfuse stack Part 1 already praised — whether "network
correct" also meant "privilege correct." It didn't, for two of the six containers. Here's
what fixing that actually looked like, including the part that broke.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="checking-whos-actually-root">Checking who's actually root<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#checking-whos-actually-root" class="hash-link" aria-label="Direct link to Checking who's actually root" title="Direct link to Checking who's actually root" translate="no">​</a></h2>
<p>Docker doesn't tell you this by default — a container can be published correctly,
healthy, and running as <code>root</code> the entire time, with nothing in <code>docker ps</code> hinting at
it. The only way to know is to ask the container directly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-postgres-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-redis-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">0</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Both id -u checks returning 0 — both containers running as root" src="https://development-wec.wiline.com/docs/assets/images/privsec-root-confirmed-9af63f21bf8c07c7554a6cbab4f02929.png" width="617" height="96" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The check that most setups never run: both <code>id -u</code> calls come back <code>0</code>.
Healthy, published correctly, and root the entire time.</p>
<p><code>0</code> is root. Both were running as root, and looking at the compose file explains why —
neither service has a <code>user:</code> directive at all:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml (before)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker.io/redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">7</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">command</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">&gt;</span><span class="token scalar string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">      --requirepass ${REDIS_AUTH:-myredissecret}</span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">      --maxmemory-policy noeviction</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker.io/postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">$</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">POSTGRES_VERSION</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">-17</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">...</span><br></div></code></pre></div></div>
<p>Nobody decided these should run as root. It's just what happens when you don't set
anything — same shape as Part 1's finding that an unset Redis password defaults to no
authentication at all. The <code>clickhouse</code> service in the same file shows the fix already
half-done, sitting right there for comparison:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml (already correct)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">clickhouse</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker.io/clickhouse/clickhouse</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">server</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">user</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"101:101"</span><br></div></code></pre></div></div>
<p>One line. Somebody set it for ClickHouse and never got to the other two.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-the-right-user-not-a-guess">Finding the right user, not a guess<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#finding-the-right-user-not-a-guess" class="hash-link" aria-label="Direct link to Finding the right user, not a guess" title="Direct link to Finding the right user, not a guess" translate="no">​</a></h2>
<p>Don't invent a UID — both official images already ship a non-root user built for exactly
this, and guessing wrong means either a permission error or, worse, a UID that silently
doesn't match the one the image's own files are owned by:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-postgres-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> postgres</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-redis-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> redis</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">uid=999(redis) gid=999(redis) groups=999(redis)</span><br></div></code></pre></div></div>
<p>Both images happen to use <code>999:999</code> for their built-in non-root user. Add it to the
compose file:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml (after)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker.io/redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">7</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">user</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"999:999"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker.io/postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">$</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">POSTGRES_VERSION</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">-17</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">user</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"999:999"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">...</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-conversion-itself-was-a-non-event">The conversion itself was a non-event<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#the-conversion-itself-was-a-non-event" class="hash-link" aria-label="Direct link to The conversion itself was a non-event" title="Direct link to The conversion itself was a non-event" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> --force-recreate postgres redis</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Container langfuse-postgres-1 Recreated</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> ✔ Container langfuse-redis-1 Recreated</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">5</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-postgres-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-redis-1 </span><span class="token function" style="color:#d73a49">id</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-u</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-postgres-1   Up 34 seconds (healthy)   127.0.0.1:5433-&gt;5432/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-redis-1      Up 34 seconds (healthy)   127.0.0.1:6379-&gt;6379/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">999</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">999</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose ps showing both containers healthy, and id -u now returning 999" src="https://development-wec.wiline.com/docs/assets/images/privsec-nonroot-healthy-2aa6142e51931837aa80ee308e8be448.png" width="1584" height="384" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Same two containers, seconds after <code>--force-recreate</code>: both <code>healthy</code>, both
<code>id -u</code> now <code>999</code> instead of <code>0</code>.</p>
<p>No permission errors, no ownership drama, both healthy within seconds. That's worth
saying plainly because it's not always true — if these volumes had been initialized as
root somewhere upstream and never <code>chown</code>-ed, this exact command can fail with
<code>permission denied</code> on the data directory. It didn't here, but check your own logs after
recreating, don't assume "no output" means "no problem."</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can check whether a running container is actually root (<code>docker exec &lt;container&gt; id -u</code>), find its image's intended non-root user instead of guessing a UID
(<code>docker exec &lt;container&gt; id &lt;username&gt;</code>), and convert it with a one-line <code>user:</code> in
compose.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-part-that-actually-broke">The part that actually broke<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#the-part-that-actually-broke" class="hash-link" aria-label="Direct link to The part that actually broke" title="Direct link to The part that actually broke" translate="no">​</a></h2>
<p>Everything above was clean. Then the logs from a completely different container told a
different story:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs langfuse-langfuse-worker-1 </span><span class="token parameter variable" style="color:#36acaa">--tail</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">20</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Error: getaddrinfo ENOTFOUND redis</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Redis error getaddrinfo ENOTFOUND redis</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Redis error connect ECONNREFUSED 172.19.0.5:6379</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Queue job mixpanel-integration-processing-queue errored: Error: Socket timeout.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Expecting data, but didn't receive any in 30000ms.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Queue job trace-delete errored: Error: Socket timeout. Expecting data, but didn't</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">receive any in 30000ms.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="langfuse-worker&amp;#39;s logs showing repeated 30-second socket timeouts against Redis, one per queue" src="https://development-wec.wiline.com/docs/assets/images/privsec-worker-broken-e776f29e677755bd8a5e61864dbe22bc.png" width="995" height="428" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> A container nobody touched, failing anyway — every queue job erroring with
the same 30-second socket timeout against Redis, well after the recreate that caused it.</p>
<p><code>langfuse-worker</code> never touched Postgres or Redis's ownership — it just holds a live
connection to Redis, and <code>--force-recreate</code> tore down the container that connection
pointed at. Docker's own healthcheck said Redis was <code>healthy</code> again well before the
worker gave up retrying; ioredis just doesn't reconnect cleanly on its own here, and
kept failing with 30-second socket timeouts long after the dependency it depended on was
back.</p>
<p>The fix is the dependent service's own restart, not another look at Redis:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart langfuse-worker langfuse-web</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">2026-08-10T19:40:22.505Z info    Redis connection has been closed.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">2026-08-10T19:40:22.769Z info    Shutdown complete, exiting process...</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">10</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> logs langfuse-langfuse-worker-1 </span><span class="token parameter variable" style="color:#36acaa">--tail</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">15</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">experiment-create-queue executor started: true</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">posthog-integration-queue executor started: true</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">data-retention-queue executor started: true</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">webhook-queue executor started: true</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Listening: http://21caf9a28775:3030</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="langfuse-worker&amp;#39;s logs after restart, showing every queue executor starting cleanly" src="https://development-wec.wiline.com/docs/assets/images/privsec-worker-recovered-ca07f68b5e4cb0994524f20c497f41ca.png" width="994" height="372" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Same container, one restart later — every queue executor starts clean, no
Redis errors anywhere in the log.</p>
<p>Clean queue startup, no Redis errors. That's the whole lesson: <strong>hardening a container
doesn't stay contained to that container.</strong> Anything holding a live connection to the
thing you just recreated needs its own restart, on purpose, not as an afterthought you
discover from an error log twenty minutes later.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>This is the one to remember</div><div class="admonitionContent_BuS1"><p><code>--force-recreate</code> on a shared dependency (a database, a queue, a cache) can silently
break every service that already had a connection open to it — even after the
dependency itself reports healthy again. Restart dependents explicitly; don't assume
they'll notice on their own.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-found-and-what-it-cost-to-fix">What we found, and what it cost to fix<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#what-we-found-and-what-it-cost-to-fix" class="hash-link" aria-label="Direct link to What we found, and what it cost to fix" title="Direct link to What we found, and what it cost to fix" translate="no">​</a></h2>
<table><thead><tr><th>Container</th><th>Before</th><th>Fix</th><th>Broke anything?</th></tr></thead><tbody><tr><td><code>langfuse-postgres-1</code></td><td>root (<code>uid 0</code>)</td><td><code>user: "999:999"</code></td><td>No — recreated clean</td></tr><tr><td><code>langfuse-redis-1</code></td><td>root (<code>uid 0</code>)</td><td><code>user: "999:999"</code></td><td>No — recreated clean</td></tr><tr><td><code>langfuse-clickhouse-1</code></td><td>already <code>101:101</code></td><td>none needed</td><td>—</td></tr><tr><td><code>langfuse-langfuse-worker-1</code></td><td>untouched</td><td>none — just needed a restart</td><td>Yes — lost its Redis connection after the <em>other</em> two containers recreated</td></tr></tbody></table>
<p>Two one-line fixes. The actual cost wasn't the fix — it was noticing the third
container that nobody touched was the one that broke.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="docker-compose-restart-said-no-configuration-file-provided-not-found"><code>docker compose restart</code> said "no configuration file provided: not found"<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#docker-compose-restart-said-no-configuration-file-provided-not-found" class="hash-link" aria-label="Direct link to docker-compose-restart-said-no-configuration-file-provided-not-found" title="Direct link to docker-compose-restart-said-no-configuration-file-provided-not-found" translate="no">​</a></h3>
<p>You're not in the project directory Compose expects — it only finds <code>docker-compose.yml</code>
relative to your current working directory (or via <code>-f</code>). <code>cd</code> into the stack's own
folder first; this exact error means Compose never even looked at your containers.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-workers-still-erroring-after-i-restarted-redis">The worker's still erroring after I restarted Redis<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#the-workers-still-erroring-after-i-restarted-redis" class="hash-link" aria-label="Direct link to The worker's still erroring after I restarted Redis" title="Direct link to The worker's still erroring after I restarted Redis" translate="no">​</a></h3>
<p>Restarting the <em>dependency</em> doesn't help — the connection that broke belongs to the
<em>dependent</em> service. Restart the thing holding the connection (here, <code>langfuse-worker</code>),
not the thing it connects to.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="docker-exec-container-id-username-returns-no-such-user"><code>docker exec &lt;container&gt; id &lt;username&gt;</code> returns "no such user"<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#docker-exec-container-id-username-returns-no-such-user" class="hash-link" aria-label="Direct link to docker-exec-container-id-username-returns-no-such-user" title="Direct link to docker-exec-container-id-username-returns-no-such-user" translate="no">​</a></h3>
<p>The image doesn't ship a dedicated non-root user under that name — check the image's own
documentation, or <code>docker exec &lt;container&gt; cat /etc/passwd</code> to see what's actually
available before picking a UID.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Run containers as non-root</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Privilege is the second layer: network hardening keeps a compromise from spreading
between stacks, non-root keeps a compromise from owning the whole container. The last
layer in this series is identity — putting real authentication in front of the ports
that must stay exposed, which is where <a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">NetBird</a>
and Caddy come in.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.docker.com/reference/dockerfile/#user" target="_blank" rel="noopener noreferrer" class="">Docker docs — specify a user to run the container</a></li>
<li class=""><a href="https://hub.docker.com/_/postgres" target="_blank" rel="noopener noreferrer" class="">PostgreSQL Docker image — running as a non-root user</a></li>
<li class=""><a href="https://github.com/redis/ioredis#connection-events" target="_blank" rel="noopener noreferrer" class="">ioredis — connection and reconnection behavior</a></li>
</ul>]]></content:encoded>
            <category>docker</category>
            <category>security</category>
            <category>containers</category>
            <category>self-hosting</category>
            <category>agents</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Your firewall is lying to you: hardening Docker networks for multi-agent systems]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/</guid>
            <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A box running five agent stacks, a firewall set to deny everything, and services still answering from the public internet. We probe a real deployment, find a database with no password, prove why UFW never sees Docker traffic, and fix it four ways — every command and result from a live run.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 10 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->10</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->10<!-- --> earned</span></div><div class="skillTracker__series">Hardening self-hosted AI infra</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">Lock down Docker networks</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/harden-container-privilege-non-root/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Run containers as non-root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sso-langfuse-authentik-oidc/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Identity in front of every port</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/authentik-groups-gateway-access-control/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Membership, not just an account</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/oauth2-client-credentials-agent-gateway/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">An identity for the agent, not a key</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/mcp-server-jwt-auth/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">A tool server that checks who is asking</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/agent-egress-firewall-docker/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Where the agent can go, not just what it can call</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rootless-docker-agent-host/"><span class="skillTracker__dot" data-state="locked">8</span><span class="skillTracker__skill" data-state="locked">Move the daemon off root</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/sandbox-untrusted-agent-code/"><span class="skillTracker__dot" data-state="locked">9</span><span class="skillTracker__skill" data-state="locked">Run the model's own code without trusting it</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/per-tool-scopes-mcp-challenge/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">A scope per tool, and a refusal clients can act on</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>You start with one agent. Then it needs a database. Then you add a second agent, a
messaging bridge, an observability stack. Six months later a single WEC Instance is running
five compose projects, twenty-something containers, and nobody remembers which ports are
open to the world.</p>
<p>That's not a hypothetical — that's the box this tutorial was written on. So instead of
theorizing, we probed it: can containers reach each other across stacks? Can they reach the
databases? Is the firewall actually protecting anything?</p>
<p>Three of the answers surprised me. One of them was a database sitting there with no
password. And the firewall — the firewall was lying.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-box-were-auditing">The box we're auditing<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#the-box-were-auditing" class="hash-link" aria-label="Direct link to The box we're auditing" title="Direct link to The box we're auditing" translate="no">​</a></h2>
<p>Five independent compose projects, deployed over months, each with its own network (<code>host</code>, <code>none</code> and <code>bridge</code> are Docker's built-ins, plus one leftover from a service that isn't running):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> network </span><span class="token function" style="color:#d73a49">ls</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NETWORK ID     NAME                             DRIVER    SCOPE</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">655850b24125   bridge                           bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">05e5d49e7e9d   evolution-api_default            bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">e8bd75ecbb0a   host                             host      local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ce697baac173   langfuse_default                 bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ead3e0822ce6   nlp-evaluation-service_nlp-net   bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">5d0ab32d14d7   none                             null      local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">9a6727150f2b   openclaw_default                 bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ec77b98f457b   rag-service_default              bridge    local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">5f88cabf97f0   remark42_default                 bridge    local</span><br></div></code></pre></div></div>
<p>And here's what's published to the world:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--format</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.Names}}\t{{.Ports}}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'0.0.0.0'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">remark42-remark42-1             0.0.0.0:8082-&gt;8080/tcp, [::]:8082-&gt;8080/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">evolution-api-evolution-api-1   0.0.0.0:8080-&gt;8080/tcp, [::]:8080-&gt;8080/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-langfuse-web-1         0.0.0.0:3001-&gt;3000/tcp, [::]:3001-&gt;3000/tcp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">langfuse-minio-1                0.0.0.0:9090-&gt;9000/tcp, [::]:9090-&gt;9000/tcp, 127.0.0.1:9091-&gt;9001/tcp</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The exposure audit — nine networks listed, four services bound to every interface" src="https://development-wec.wiline.com/docs/assets/images/netsec-exposure-audit-904ef03d2c7552d9b1b8c9d9e69218dd.png" width="1045" height="300" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The start of any audit. Nine networks: three Docker built-ins (<code>bridge</code>, <code>host</code>,
<code>none</code>), five compose projects, and one leftover. Four services published on <code>0.0.0.0</code> — every
interface, including the public one.</p>
<p>Four services listening on every interface. Note what's <em>not</em> in that list: the Postgres,
ClickHouse and Redis behind Langfuse. Those were published to <code>127.0.0.1</code> instead — someone
made a good decision there. Hold that thought.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-1--do-separate-docker-networks-actually-isolate-yes-and-most-blogs-are-wrong">Test 1 — Do separate Docker networks actually isolate? (yes, and most blogs are wrong)<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#test-1--do-separate-docker-networks-actually-isolate-yes-and-most-blogs-are-wrong" class="hash-link" aria-label="Direct link to Test 1 — Do separate Docker networks actually isolate? (yes, and most blogs are wrong)" title="Direct link to Test 1 — Do separate Docker networks actually isolate? (yes, and most blogs are wrong)" translate="no">​</a></h2>
<p>The classic worry: OpenClaw gets compromised, and the attacker walks into Langfuse's
database next door. Let's find out instead of guessing. Grab the container IPs:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">c</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> langfuse-postgres-1 evolution-api-evolution-postgres-1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> inspect </span><span class="token parameter variable" style="color:#36acaa">-f</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$c</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">/langfuse-postgres-1 172.19.0.5</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/evolution-api-evolution-postgres-1 172.23.0.4</span><br></div></code></pre></div></div>
<p>Now try to reach both <strong>from inside the OpenClaw container</strong>, by IP — no DNS, no shortcuts:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 </span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">const net=require("net");</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">[["langfuse-postgres","172.19.0.5",5432],["evolution-postgres","172.23.0.4",5432]]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  .forEach(([n,ip,p])=&gt;{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    const s=net.connect({host:ip,port:p,timeout:4000});</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    s.on("connect",()=&gt;{console.log(`REACHED   ${n}`);s.destroy()});</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    s.on("timeout",()=&gt;{console.log(`blocked   ${n}`);s.destroy()});</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    s.on("error",e=&gt;console.log(`error     ${n} ${e.code}`));</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  });'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">blocked   langfuse-postgres</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">blocked   evolution-postgres</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Cross-network probes timing out from inside the OpenClaw container" src="https://development-wec.wiline.com/docs/assets/images/netsec-isolation-blocked-52d0644c0dbd63427ca62b09eb5b9947.png" width="759" height="182" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Connecting by raw IP from one stack's container to another stack's database:
both time out. Docker's isolation rules are doing their job.</p>
<p><strong>Blocked.</strong> Modern Docker installs <code>DOCKER-ISOLATION-STAGE</code> rules that drop traffic between
user-defined bridge networks, and they work. If you've read that "Docker networks don't
really isolate," test it on your own box before believing it — on current Docker they do.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can verify network isolation empirically instead of trusting folklore: get the target's
IP with <code>docker inspect</code>, then connect <strong>by IP</strong> from inside another container.</p></div></div>
<p>So cross-stack isolation is fine. Which means the real risk is somewhere else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-2--inside-a-network-everything-is-wide-open">Test 2 — Inside a network, everything is wide open<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#test-2--inside-a-network-everything-is-wide-open" class="hash-link" aria-label="Direct link to Test 2 — Inside a network, everything is wide open" title="Direct link to Test 2 — Inside a network, everything is wide open" translate="no">​</a></h2>
<p>Each compose project puts all its services on one network. That's the default, and it means
the application container can reach every backing service — which is <em>intended</em>. The
question is what that access actually looks like. From Evolution API's bridge container:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> evolution-api-bridge-1 python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for name, host, port in [("postgres","evolution-postgres",5432),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                         ("redis","evolution-redis",6379),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                         ("api","evolution-api",8080)]:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    s=socket.socket(); s.settimeout(4)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    try:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        s.connect((host,port)); print(f"REACHED   {name}:{port}")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    except Exception as e:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        print(f"blocked   {name}:{port} -- {type(e).__name__}")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    finally: s.close()'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">REACHED   postgres:5432</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">REACHED   redis:6379</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">REACHED   api:8080</span><br></div></code></pre></div></div>
<p>Reachable is expected. <strong>Unauthenticated is not.</strong> Check whether those services actually ask
for credentials — first Redis:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> evolution-api-bridge-1 python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s=socket.socket(); s.settimeout(5); s.connect(("evolution-redis",6379))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s.sendall(b"INFO server\r\n")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print(s.recv(200).decode(errors="replace")[:120])'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">$625</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"># Server</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">redis_version:7.4.9</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">redis_git_sha1:00000000</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">redis_git_dirty:0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">redis_build_id:b61b4eb609520881</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">redis_</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Redis answering INFO with no AUTH" src="https://development-wec.wiline.com/docs/assets/images/netsec-redis-noauth-22e25e2b5bbf007d514a914ec9ec8b91.png" width="744" height="226" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> The finding: <code>INFO server</code> answered with no credentials (output truncated at 120
chars by the probe). Any container on this network can read or wipe the session store.</p>
<p>That's a <strong><code>INFO</code> command answered with no <code>AUTH</code></strong> — the Redis holding this deployment's
session state has no password. Anything that gets code execution in <em>any</em> container on that
network can read it, write to it, or <code>FLUSHALL</code> it.</p>
<p>Postgres, in the same stack, behaves correctly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> evolution-api-bridge-1 python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket,struct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s=socket.socket(); s.settimeout(5); s.connect(("evolution-postgres",5432))</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">msg=b"user\x00postgres\x00database\x00postgres\x00\x00"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s.sendall(struct.pack("!ii",len(msg)+8,196608)+msg)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print(s.recv(64))'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">b'R\x00\x00\x00\x17\x00\x00\x00\nSCRAM-SHA-256\x00\x00'</span><br></div></code></pre></div></div>
<p>It demands SCRAM-SHA-256. Same box, same network, two backing services — one asks for a
password, the other doesn't. Nobody decided that; it's just what the images default to when
you don't set a password.</p>
<p><strong>The lesson: "internal network" is not a security boundary.</strong> It's a convenience. Treat
every service as if the attacker is already on that network, because if one container falls,
they are.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-3--the-firewall-that-isnt">Test 3 — The firewall that isn't<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#test-3--the-firewall-that-isnt" class="hash-link" aria-label="Direct link to Test 3 — The firewall that isn't" title="Direct link to Test 3 — The firewall that isn't" translate="no">​</a></h2>
<p>Now the big one. Turn on UFW and deny everything inbound except SSH:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ufw allow </span><span class="token number" style="color:#36acaa">22</span><span class="token plain">/tcp          </span><span class="token comment" style="color:#999988;font-style:italic"># ALWAYS first, or you lock yourself out</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ufw default deny incoming</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ufw </span><span class="token parameter variable" style="color:#36acaa">--force</span><span class="token plain"> </span><span class="token builtin class-name">enable</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ufw status verbose</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Status: active</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Logging: on (low)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Default: deny (incoming), allow (outgoing), deny (routed)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">New profiles: skip</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">To                         Action      From</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">--                         ------      ----</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">60000:61000/udp            ALLOW IN    Anywhere</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">22/tcp                     ALLOW IN    Anywhere</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">60000:61000/udp (v6)       ALLOW IN    Anywhere (v6)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">22/tcp (v6)                ALLOW IN    Anywhere (v6)</span><br></div></code></pre></div></div>
<p>Deny incoming — only SSH and mosh's UDP range allowed. Now, from a <strong>different machine</strong>,
open the Langfuse UI and the Evolution API:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">From a laptop, not the server</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">http://&lt;instance-ip&gt;:3001   -&gt;  Langfuse UI loads normally</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">http://&lt;instance-ip&gt;:8080   -&gt;  {"status":200,"message":"Welcome to the Evolution API..."}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The Evolution API answering in a browser while UFW denies all incoming traffic" src="https://development-wec.wiline.com/docs/assets/images/netsec-ufw-bypass-130569873d3680226c3534f016c4756a.png" width="474" height="239" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 4.</strong> The firewall says it denies everything inbound. The Evolution API on <code>:8080</code>
answers anyway — status 200, straight from a browser on another machine.</p>
<p>Both wide open, through a firewall configured to block them. This is the single most common
security surprise in self-hosted Docker, and it isn't a bug.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-docker-gets-to-the-packet-first">Why: Docker gets to the packet first<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#why-docker-gets-to-the-packet-first" class="hash-link" aria-label="Direct link to Why: Docker gets to the packet first" title="Direct link to Why: Docker gets to the packet first" translate="no">​</a></h3>
<p>Look at the order of the <code>FORWARD</code> chain:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> FORWARD </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> --line-numbers </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">head</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-8</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Chain FORWARD (policy DROP)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">num  target     prot opt source               destination</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">1    DOCKER-USER  all  --  0.0.0.0/0            0.0.0.0/0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">2    DOCKER-FORWARD  all  --  0.0.0.0/0            0.0.0.0/0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">3    ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">4    ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">5    ufw-before-logging-forward  all  --  0.0.0.0/0            0.0.0.0/0</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">6    ufw-before-forward  all  --  0.0.0.0/0            0.0.0.0/0</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="UFW active with deny incoming, and Docker&amp;#39;s chains ahead of UFW&amp;#39;s in FORWARD" src="https://development-wec.wiline.com/docs/assets/images/netsec-ufw-chains-7a5553d8ce1882eb415c7e12e1beb065.png" width="1031" height="395" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> The whole explanation in one screen: the firewall says <em>deny incoming</em>, and
Docker's chains sit at positions 1-2 while UFW's start at 5.</p>
<p>Docker's chains sit at positions <strong>1 and 2</strong>. UFW's don't appear until <strong>5 and 6</strong>. And
publishing a port isn't a listener on the host — it's a NAT rule:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-t</span><span class="token plain"> nat </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> DOCKER </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'3001|8080'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">DNAT tcp -- 0.0.0.0/0  0.0.0.0/0  tcp dpt:8080 to:172.23.0.3:8080</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">DNAT tcp -- 0.0.0.0/0  0.0.0.0/0  tcp dpt:3001 to:172.19.0.2:3000</span><br></div></code></pre></div></div>
<p>The packet arrives, Docker's NAT rewrites the destination to the container, and it becomes
<em>forwarded</em> traffic that Docker's own chain accepts — long before any UFW rule is consulted.
Your UFW rules aren't ignored; they're simply never reached.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>This is the one to remember</div><div class="admonitionContent_BuS1"><p><code>ufw deny</code> does <strong>not</strong> protect a published Docker port. If you've been relying on UFW in
front of <code>-p 8080:8080</code>, that port has been open the whole time.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="four-fixes-in-order-of-preference">Four fixes, in order of preference<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#four-fixes-in-order-of-preference" class="hash-link" aria-label="Direct link to Four fixes, in order of preference" title="Direct link to Four fixes, in order of preference" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="fix-1--dont-publish-it-at-all">Fix 1 — Don't publish it at all<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#fix-1--dont-publish-it-at-all" class="hash-link" aria-label="Direct link to Fix 1 — Don't publish it at all" title="Direct link to Fix 1 — Don't publish it at all" translate="no">​</a></h3>
<p>The cleanest fix isn't a firewall rule; it's not opening the door. If a service is only
consumed by other containers, it needs <strong>no</strong> <code>ports:</code> entry — service-to-service traffic
works over the compose network by name.</p>
<p>If you need it reachable from the host (a debugging port, a local psql), bind it to
loopback explicitly:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic"># 127.0.0.1 prefix = host-only, never the public interface</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">ports</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"127.0.0.1:5433:5432"</span><br></div></code></pre></div></div>
<p>That's exactly what the Langfuse stack on this box already does — its Postgres, ClickHouse
and Redis are all on <code>127.0.0.1</code>, which is why they never showed up in our exposure audit.
The default <code>"5433:5432"</code> means <code>0.0.0.0</code> — every interface, including the public one.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="fix-2--split-networks-by-trust-zone-and-mark-backends-internal">Fix 2 — Split networks by trust zone, and mark backends <code>internal</code><a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#fix-2--split-networks-by-trust-zone-and-mark-backends-internal" class="hash-link" aria-label="Direct link to fix-2--split-networks-by-trust-zone-and-mark-backends-internal" title="Direct link to fix-2--split-networks-by-trust-zone-and-mark-backends-internal" translate="no">​</a></h3>
<p>One network per compose project is the default, not a design. Give the backing services
their own network, and put only the application in both:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">app</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">                      </span><span class="token comment" style="color:#999988;font-style:italic"># talks to the world AND to the database</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> your</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">agent</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">networks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">frontend</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> backend</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">7</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">command</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">server </span><span class="token punctuation" style="color:#393A34">-</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">requirepass $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">REDIS_PASSWORD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">networks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">backend</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">     </span><span class="token comment" style="color:#999988;font-style:italic"># backend only — no route out</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">public</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">                   </span><span class="token comment" style="color:#999988;font-style:italic"># e.g. a webhook receiver</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> your</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">public</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">thing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">networks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">frontend</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic"># cannot see the database at all</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">networks</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">frontend</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">backend</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">internal</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><span class="token plain">          </span><span class="token comment" style="color:#999988;font-style:italic"># no gateway: no inbound, no outbound</span><br></div></code></pre></div></div>
<p>Verified on the same box:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># the frontend-only container tries to reach the database</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> netsecdemo-public-1 python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s=socket.socket(); s.settimeout(4)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">try: s.connect(("db",6379)); print("REACHED db  &lt;- leak")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">except Exception as e: print(f"blocked: {type(e).__name__}")'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># the app, which is on both networks</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> netsecdemo-app-1 python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s=socket.socket(); s.settimeout(4)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">try: s.connect(("db",6379)); print("REACHED db  &lt;- correct, it needs it")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">except Exception as e: print(f"blocked: {type(e).__name__}")'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># and can the database itself reach the internet?</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> netsecdemo-db-1 </span><span class="token function" style="color:#d73a49">timeout</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">6</span><span class="token plain"> getent hosts pypi.org </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">/dev/null </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"resolved external DNS"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"no internet egress  &lt;- internal working"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">blocked: gaierror</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">REACHED db  &lt;- correct, it needs it</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">no internet egress  &lt;- internal working</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The internal-network demo: public blocked, app allowed, database with no egress" src="https://development-wec.wiline.com/docs/assets/images/netsec-internal-demo-6231d6936686cf9d282c7f494797a185.png" width="371" height="163" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 6.</strong> Three properties proven in one run — the public-facing container can't even
resolve the database, the app can reach it, and the database has no route to the internet.</p>
<p>Three properties at once: the public-facing container can't even resolve the database, the
app can, and the database can't phone home — which matters the day a dependency of yours
turns malicious.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="fix-3--authenticate-everything-including-internal-services">Fix 3 — Authenticate everything, including "internal" services<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#fix-3--authenticate-everything-including-internal-services" class="hash-link" aria-label="Direct link to Fix 3 — Authenticate everything, including &quot;internal&quot; services" title="Direct link to Fix 3 — Authenticate everything, including &quot;internal&quot; services" translate="no">​</a></h3>
<p>Our audit found Redis answering <code>INFO</code> with no credentials. One line fixes it:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">db</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">7</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">command</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">server </span><span class="token punctuation" style="color:#393A34">-</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">requirepass $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">REDIS_PASSWORD</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (after)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">-NOAUTH Authentication required.</span><br></div></code></pre></div></div>
<p>That's the real output from this box, after applying the fix — the finding from Test 2 is
closed. If you set a password on a Redis that something already uses, remember to update the
client's connection string too (<code>redis://:&lt;password&gt;@host:6379/0</code>), or you'll trade an open
database for a broken one.</p>
<p>Do this even for services that aren't published. Defense in depth means the second layer
holds when the first one fails.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="fix-4--when-you-must-publish-filter-in-docker-user">Fix 4 — When you must publish, filter in <code>DOCKER-USER</code><a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#fix-4--when-you-must-publish-filter-in-docker-user" class="hash-link" aria-label="Direct link to fix-4--when-you-must-publish-filter-in-docker-user" title="Direct link to fix-4--when-you-must-publish-filter-in-docker-user" translate="no">​</a></h3>
<p>Sometimes a port genuinely has to be public-facing but restricted by source. The chain to
use is <code>DOCKER-USER</code> — Docker guarantees it runs <strong>before</strong> its own accept rules, and never
overwrites it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># only 203.0.113.0/24 may open new connections to the container's port 3000</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-I</span><span class="token plain"> DOCKER-</span><span class="token environment constant" style="color:#36acaa">USER</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> tcp </span><span class="token parameter variable" style="color:#36acaa">--dport</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3000</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> conntrack </span><span class="token parameter variable" style="color:#36acaa">--ctstate</span><span class="token plain"> NEW </span><span class="token operator" style="color:#393A34">!</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">203.0</span><span class="token plain">.113.0/24 </span><span class="token parameter variable" style="color:#36acaa">-j</span><span class="token plain"> DROP</span><br></div></code></pre></div></div>
<p>Tested live on the Langfuse port, from a browser on another machine:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Before the rule</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">http://&lt;instance-ip&gt;:3001  -&gt;  Langfuse UI loads</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">After the rule</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ERR_CONNECTION_TIMED_OUT</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The same URL timing out after the DOCKER-USER rule" src="https://development-wec.wiline.com/docs/assets/images/netsec-docker-user-blocked-8e34c54fd3b4552806e49bf87aed7cfb.png" width="2524" height="1288" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><strong>Figure 7.</strong> Same URL, same firewall, one rule in the right chain — and the port is finally
closed. Note Chrome's own suggestion: <em>"Checking the proxy and the firewall."</em> This time the
firewall really is the answer.</p>
<p>Meanwhile the Evolution API on <code>:8080</code>, which we deliberately left alone, kept answering.
The rule is surgical, and it works exactly where UFW couldn't.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Make it survive a reboot</div><div class="admonitionContent_BuS1"><p><code>iptables -I</code> is not persistent. Use <code>iptables-persistent</code> (<code>netfilter-persistent save</code>), or
put the rule in a small systemd unit that runs after <code>docker.service</code>. A firewall rule that
disappears on reboot is worse than none, because you'll think you're protected.</p></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can audit and lock down a multi-stack Docker host: prove what's reachable, bind or split
what shouldn't be, authenticate the backends, and filter published ports in the one chain
Docker respects.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-audit-as-a-checklist">The audit, as a checklist<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#the-audit-as-a-checklist" class="hash-link" aria-label="Direct link to The audit, as a checklist" title="Direct link to The audit, as a checklist" translate="no">​</a></h2>
<p>Run these four commands on any box you own. They take a minute and tell you more than any
architecture diagram:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># 1. What's exposed to the world?</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--format</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{{.Names}}\t{{.Ports}}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'0.0.0.0'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 2. Are your firewall rules even in the path? (Docker chains before ufw = they aren't)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> iptables </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> FORWARD </span><span class="token parameter variable" style="color:#36acaa">-n</span><span class="token plain"> --line-numbers </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">head</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-8</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 3. What can a compromised container reach on its own network?</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">container</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import socket;s=socket.socket();s.settimeout(3);s.connect</span><span class="token string variable punctuation" style="color:#393A34">((</span><span class="token string variable" style="color:#36acaa">'</span><span class="token string variable operator" style="color:#393A34">&lt;</span><span class="token string variable" style="color:#36acaa">svc</span><span class="token string variable operator" style="color:#393A34">&gt;</span><span class="token string variable" style="color:#36acaa">'</span><span class="token string variable punctuation" style="color:#393A34">,</span><span class="token string variable operator" style="color:#393A34">&lt;</span><span class="token string variable" style="color:#36acaa">port</span><span class="token string variable operator" style="color:#393A34">&gt;</span><span class="token string variable punctuation" style="color:#393A34">))</span><span class="token string" style="color:#e3116c">;print('reached')"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 4. Does that backing service ask for a password?</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">container</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import socket;s=socket.socket();s.settimeout(4);s.connect</span><span class="token string variable punctuation" style="color:#393A34">((</span><span class="token string variable" style="color:#36acaa">'redis'</span><span class="token string variable punctuation" style="color:#393A34">,</span><span class="token string variable number" style="color:#36acaa">6379</span><span class="token string variable punctuation" style="color:#393A34">))</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">s.sendall(b'INFO server</span><span class="token string entity" style="color:#36acaa">\r</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">');print(s.recv(60))"</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-found-and-what-it-cost-to-fix">What we found, and what it cost to fix<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#what-we-found-and-what-it-cost-to-fix" class="hash-link" aria-label="Direct link to What we found, and what it cost to fix" title="Direct link to What we found, and what it cost to fix" translate="no">​</a></h2>
<table><thead><tr><th>Finding</th><th>Severity</th><th>Fix</th></tr></thead><tbody><tr><td>Cross-network isolation works</td><td>✅ none</td><td>nothing — Docker already does this</td></tr><tr><td>Every service on one network, mutually reachable</td><td>⚠️ medium</td><td>split by trust zone, <code>internal: true</code></td></tr><tr><td>Redis answering with no <code>AUTH</code></td><td>🔴 high</td><td>one line: <code>--requirepass</code></td></tr><tr><td><code>ufw deny</code> not applied to published ports</td><td>🔴 high</td><td>bind to <code>127.0.0.1</code>, or <code>DOCKER-USER</code> rule</td></tr><tr><td>Backing DBs on <code>127.0.0.1</code> (Langfuse)</td><td>✅ none</td><td>already correct — copy this pattern</td></tr></tbody></table>
<p>None of the fixes took more than a line of YAML. The expensive part was <em>knowing which line</em>
— and the only way to know is to probe your own box instead of trusting the diagram in your
head.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="i-added-a-docker-user-rule-and-nothing-changed">I added a <code>DOCKER-USER</code> rule and nothing changed<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#i-added-a-docker-user-rule-and-nothing-changed" class="hash-link" aria-label="Direct link to i-added-a-docker-user-rule-and-nothing-changed" title="Direct link to i-added-a-docker-user-rule-and-nothing-changed" translate="no">​</a></h3>
<p>Two usual causes. First, <code>--dport</code> must be the <strong>container's</strong> port, not the published one —
the DNAT already rewrote the destination by the time <code>DOCKER-USER</code> sees the packet (in our
case <code>3000</code>, not <code>3001</code>). Second, existing connections aren't affected: the
<code>--ctstate NEW</code> match only touches new ones, so an already-open browser tab keeps working.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="i-enabled-ufw-and-lost-my-ssh-session">I enabled UFW and lost my SSH session<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#i-enabled-ufw-and-lost-my-ssh-session" class="hash-link" aria-label="Direct link to I enabled UFW and lost my SSH session" title="Direct link to I enabled UFW and lost my SSH session" translate="no">​</a></h3>
<p><code>sudo ufw allow 22/tcp</code> <strong>before</strong> <code>ufw enable</code>, always. If you're already locked out, most
providers give you a serial/VNC console — use it to run <code>ufw disable</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="internal-true-broke-my-containers-package-install"><code>internal: true</code> broke my container's package install<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#internal-true-broke-my-containers-package-install" class="hash-link" aria-label="Direct link to internal-true-broke-my-containers-package-install" title="Direct link to internal-true-broke-my-containers-package-install" translate="no">​</a></h3>
<p>That's it working. An internal network has no gateway, so no egress at all — no <code>pip install</code>, no DNS, no calling an external API. Backing services shouldn't need any of that;
if a container does, it belongs on the frontend network too.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="services-cant-find-each-other-after-i-split-the-networks">Services can't find each other after I split the networks<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#services-cant-find-each-other-after-i-split-the-networks" class="hash-link" aria-label="Direct link to Services can't find each other after I split the networks" title="Direct link to Services can't find each other after I split the networks" translate="no">​</a></h3>
<p>Compose DNS only resolves names <strong>within a shared network</strong>. If <code>app</code> and <code>db</code> are on
different networks with nothing in common, <code>db</code> won't resolve. The app has to be a member of
both, as in Fix 2.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Lock down Docker networks</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Network hardening is the layer that keeps a compromised container from becoming a
compromised host. The next layers, in the order I'd do them: <strong>run containers as non-root</strong>
(<code>user:</code> in compose) so a breakout starts with fewer privileges, and put an <strong>identity
provider in front of the ports you must expose</strong> — a private mesh with
<a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">NetBird</a> plus authentication is the natural pairing
with everything above.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/harden-docker-networks-multi-agent/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener noreferrer" class="">Docker docs — packet filtering and firewalls</a></li>
<li class=""><a href="https://docs.docker.com/engine/network/drivers/bridge/" target="_blank" rel="noopener noreferrer" class="">Docker docs — bridge network driver options</a></li>
<li class=""><a href="https://redis.io/docs/latest/operate/oss_and_stack/management/security/" target="_blank" rel="noopener noreferrer" class="">Redis security — authentication</a></li>
</ul>]]></content:encoded>
            <category>docker</category>
            <category>security</category>
            <category>networking</category>
            <category>self-hosting</category>
            <category>agents</category>
            <category>hardening</category>
        </item>
        <item>
            <title><![CDATA[Migrate OpenClaw's Telegram Bot to Hermes]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/</guid>
            <pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Move the same Telegram bot from the OpenClaw series onto Hermes — same chat, same users, a different agent answering underneath. Real setup wizard, a real allowlist gotcha, and proof it answers.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/hermesagent-c76bbe81610ae2e70b0950e0f96bec8a.png" alt="Hermes Agent"><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#26A5E4" aria-label="Telegram" class="tutorialHero__docker"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 2 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->2</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->2<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting Hermes</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Agent with persistent memory</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">Telegram on Hermes</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>In <a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Part 1</a> you self-hosted Hermes with
persistent memory. This part connects it to the <strong>same Telegram bot</strong> from the
<a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">OpenClaw series</a> — the chat your
users already know keeps working exactly as it did, just a different agent
answering underneath. No new bot to announce, no channel to migrate people to.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues on the same WEC Instance as Part 1. Hermes gateway is already
installed and running as a systemd user service — this part is configuration,
not installation. Every command and error below is from the actual run.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-keep-the-same-bot">Why keep the same bot<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#why-keep-the-same-bot" class="hash-link" aria-label="Direct link to Why keep the same bot" title="Direct link to Why keep the same bot" translate="no">​</a></h2>
<p>Self-hosting is only worth the effort if it doesn't lock you into whatever
agent you started with. The bot itself — its name, its chat history, the
people who already talk to it — is the asset. The agent behind it is
replaceable. Swapping OpenClaw for Hermes on the same bot proves that: same
Telegram front door, same conversation thread, new brain.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">Hermes running from <a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Part 1</a>, gateway
installed as a systemd service.</li>
<li class="">A Telegram bot token. Reuse the one from the
<a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">OpenClaw Telegram tutorial</a> via
<strong>@BotFather → /mybots → your bot → API Token</strong> — or create a fresh one with
<strong>/newbot</strong> if you don't have the old one handy. Either way, the steps below
are identical.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--run-the-setup-wizard">Step 1 — Run the setup wizard<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#step-1--run-the-setup-wizard" class="hash-link" aria-label="Direct link to Step 1 — Run the setup wizard" title="Direct link to Step 1 — Run the setup wizard" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hermes gateway setup</span><br></div></code></pre></div></div>
<p>Pick Telegram from the platform list, then choose how to connect it:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">◆ Telegram</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  How would you like to create your Telegram bot?</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    [1] Automatic (recommended)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        Scan a QR code → confirm in Telegram → done.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        No token copy-paste needed.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    [2] Manual</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        Create a bot via @BotFather yourself and paste the token.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Choice [1/2] [1]: 2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Create a bot via @BotFather on Telegram</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Telegram bot token: **********************************************</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ Telegram token saved</span><br></div></code></pre></div></div>
<p>Take option <strong>2</strong> — option 1 spins up a brand new bot via QR code, which
defeats the point if you're reusing an existing one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--open-access-and-a-real-gotcha">Step 2 — Open access (and a real gotcha)<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#step-2--open-access-and-a-real-gotcha" class="hash-link" aria-label="Direct link to Step 2 — Open access (and a real gotcha)" title="Direct link to Step 2 — Open access (and a real gotcha)" translate="no">​</a></h2>
<p>Next the wizard asks who's allowed to use the bot:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">🔒 Security: Restrict who can use your bot</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">   To find your Telegram user ID:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">   1. Message @userinfobot on Telegram</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">   2. It will reply with your numeric ID (e.g., 123456789)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Allowed user IDs (comma-separated, leave empty for open access):</span><br></div></code></pre></div></div>
<p>If you don't have your ID handy, leave it blank and move on — you can lock
this down later. But <strong>the prompt is misleading</strong>: leaving it empty doesn't
open access by default. The gateway logs told the real story after restart:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WARNING gateway.run: No user allowlists configured. All unauthorized users</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">will be denied. Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">open access, or configure platform allowlists (e.g.,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">TELEGRAM_ALLOWED_USERS=your_id).</span><br></div></code></pre></div></div>
<p>An empty allowlist means <strong>deny everyone</strong>, not open access. If you want open
access (fine for a personal bot, not for anything public-facing), you need
one more step:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'GATEWAY_ALLOW_ALL_USERS=true'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> ~/.hermes/.env</span><br></div></code></pre></div></div>
<p>The wizard also asks for a <strong>Home Channel</strong> — where Hermes delivers cron
results and cross-platform messages. Leave it blank too; you can set it later
from inside the chat with <code>/sethome</code>.</p>
<p>Finish the wizard and let it restart the gateway:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Restart the gateway to pick up changes? [Y/n]: Y</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ User service restarted</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal showing the full wizard run — token saved, the allowlist warning, and the gateway restart" src="https://development-wec.wiline.com/docs/assets/images/hermes-gateway-setup-wizard-7293ce9021ee147b93bad2dcb7a72a53.png" width="603" height="949" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The whole wizard, start to finish — token, the allowlist warning, and the restart.</p>
<p>If you added <code>GATEWAY_ALLOW_ALL_USERS</code> after the wizard already restarted
once (as above), restart again so it picks up the <code>.env</code> change:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> restart hermes-gateway.service</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--verify">Step 3 — Verify<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#step-3--verify" class="hash-link" aria-label="Direct link to Step 3 — Verify" title="Direct link to Step 3 — Verify" translate="no">​</a></h2>
<p>Message your bot. <strong>Not @BotFather</strong> — your bot, by the username you gave it
when you created it. It's an easy mix-up: BotFather answers everything with
its own fixed command menu, so if you see that instead of a real reply,
you're in the wrong chat.</p>
<p>The first message may get a system nudge instead of a chat reply — that's
Hermes noticing there's no home channel yet, not a failure:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">📬 No home channel is set for Telegram. A home channel is where Hermes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">delivers cron job results and cross-platform messages.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Type /sethome to make this chat your home channel, or ignore to skip.</span><br></div></code></pre></div></div>
<p>Send another message and it answers for real:</p>
<p><span class="zoomImage__wrap"><img alt="Telegram chat showing the bot replying &amp;quot;Hello! How can I help?&amp;quot; via Hermes" src="https://development-wec.wiline.com/docs/assets/images/hermes-telegram-verified-17c22677d5e1e6a1d6147099b58fdc53.png" width="1320" height="2868" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The same bot from the OpenClaw series, now answering through Hermes.</p>
<p>Ask it something with actual depth, and it delivers a real, structured answer
— not just a canned reply:</p>
<p><span class="zoomImage__wrap"><img alt="Telegram chat showing the bot explaining Rayleigh scattering in response to &amp;quot;Why is the sky blue?&amp;quot;" src="https://development-wec.wiline.com/docs/assets/images/hermes-telegram-sky-blue-139494c527c4075440bf7afa25c14039.png" width="1320" height="2868" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> Same agent, a real question — full explanation, not a one-liner.</p>
<p>Same bot, same chat history, new agent.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real">Troubleshooting (real)<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#troubleshooting-real" class="hash-link" aria-label="Direct link to Troubleshooting (real)" title="Direct link to Troubleshooting (real)" translate="no">​</a></h2>
<p><strong>"No user allowlists configured. All unauthorized users will be denied."</strong> —
leaving the allowlist prompt empty does not mean open access; it means
deny-all. Set <code>GATEWAY_ALLOW_ALL_USERS=true</code> in <code>~/.hermes/.env</code> and restart
the gateway.</p>
<p><strong>"Telegram polling conflict... make sure that only one bot instance is
running"</strong> — shows up for a few seconds right after a restart, while
Telegram's servers finish releasing the previous session. It retries and
clears on its own within 20 seconds; no action needed unless it keeps
repeating past the 5th retry, which means another process really is polling
the same token.</p>
<p><strong>Bot doesn't respond at all</strong> — check you're messaging the bot itself, not
@BotFather. BotFather always answers with its own command list regardless of
what you send it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The bot answers — next is putting it on a schedule: a morning briefing or a
service-health check delivered to this same chat, no polling required.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Telegram on Hermes</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>hermes</category>
            <category>telegram</category>
            <category>messaging</category>
        </item>
        <item>
            <title><![CDATA[Add web search to your WEC Inference calls]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Your RAG assistant answers great from your docs — but not about anything recent. WEC Inference now has a built-in web-search tool: add it to a chat call and the model pulls in current info. A quick before/after test.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/searxng-mark-1b536bb949960590cea598d56becda08.png" alt="SearXNG"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">Add live web search</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Say you've built a support chatbot or a RAG assistant on WEC. It's solid on your docs and
on what the model already knows — but ask it something <em>current</em> ("what's the latest
release of X?", today's pricing, a recent change) and it either guesses or tells you its
knowledge is out of date. For anything that needs to stay current, that's a real gap.</p>
<p>WEC Inference now has a <strong>web-search tool</strong> built in. Add it to a normal
<code>/v1/chat/completions</code> call and the model can look things up on the live web — the search
runs on WiLine's own infrastructure (SearXNG), so nothing goes out to a third-party search
vendor. Let's test it.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Calls hit <code>https://inference.wiline.com/v1/chat/completions</code> with your WEC Inference API
key, model <code>Qwen3.5-9B</code>. Nothing to install — the tool is part of the API.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="without-web-search">Without web search<a href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/#without-web-search" class="hash-link" aria-label="Direct link to Without web search" title="Direct link to Without web search" translate="no">​</a></h2>
<p>A normal call, asking something recent:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "model": "Qwen3.5-9B",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "messages": [{"role": "user", "content": "What is the latest stable version of Docker Engine, and when was it released?"}]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  }'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The model answering from training data only — an outdated version" src="https://development-wec.wiline.com/docs/assets/images/websearch-without-aeaee1a5d9a326ccb92d56a8ec08bdc5.png" width="1099" height="940" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> With no tool, the model won't commit to a version — it says its knowledge is
limited to its training cutoff and points you to the release notes instead.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="with-web-search">With web search<a href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/#with-web-search" class="hash-link" aria-label="Direct link to With web search" title="Direct link to With web search" translate="no">​</a></h2>
<p>The <strong>same</strong> request, plus a <code>web_search</code> tool and <code>tool_choice: auto</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sS</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "model": "Qwen3.5-9B",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "messages": [{"role": "user", "content": "What is the latest stable version of Docker Engine, and when was it released?"}],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "tools": [{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      "type": "function",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      "function": {</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        "name": "litellm_web_search",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        "description": "Search the web for current information",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        "parameters": {</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          "type": "object",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          "properties": {"query": {"type": "string", "description": "The search query"}},</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          "required": ["query"]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    }],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "tool_choice": "auto"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  }'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The model answering with the current version, grounded in web results" src="https://development-wec.wiline.com/docs/assets/images/websearch-with-ae28b4b9dbd33effc4676a36ce37a4e0.png" width="1100" height="731" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Same model, same question, plus the tool — now it returns a concrete version
and release date pulled from the live web. The model decided to search, WEC ran the query,
and fed the results back before answering.</p>
<p>That's the whole feature: one tool in the request body, current answers out.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-thing-to-watch-tokens">One thing to watch: tokens<a href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/#one-thing-to-watch-tokens" class="hash-link" aria-label="Direct link to One thing to watch: tokens" title="Direct link to One thing to watch: tokens" translate="no">​</a></h2>
<p>Web search injects the results into your prompt, so a searched call costs more tokens than
a bare one (in our test, prompt tokens went from ~26 to ~2,776). Keep <code>tool_choice: auto</code>
so the model only searches when it actually needs to — it'll skip search for questions it
can already answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="when-to-use-it">When to use it<a href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/#when-to-use-it" class="hash-link" aria-label="Direct link to When to use it" title="Direct link to When to use it" translate="no">​</a></h2>
<ul>
<li class=""><strong>Yes:</strong> current versions, prices, news, anything after the model's training cutoff.</li>
<li class=""><strong>Skip:</strong> stable knowledge the model already has — <code>auto</code> handles that for you.</li>
</ul>
<p>Point it at a RAG assistant or a chatbot and it can stay current without you wiring up a
separate search service.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Add live web search</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>]]></content:encoded>
            <category>ai</category>
            <category>inference</category>
            <category>web-search</category>
            <category>tool-use</category>
        </item>
        <item>
            <title><![CDATA[Component-level tracing: debugging agent tool calls]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/</guid>
            <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Your agent's thinking and its actions are two different layers. Build a tool-calling agent on WEC Inference, trace it with Langfuse, then debug two real failures from the trace — including the confident, wrong answer that never throws a stack trace.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">7</span><span class="skillTracker__skill" data-state="current">Trace &amp; debug agent tool calls</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>An agent that calls tools does two very different things: it <strong>reasons</strong> ("I should
search the docs") and it <strong>acts</strong> (actually calls the tool). When something goes wrong,
the first question is always <em>which layer failed</em> — did it think wrong, or did the doing
break? A flat log can't answer that. A <strong>trace</strong> can.</p>
<p>In this tutorial you build a small tool-calling agent on <strong>WEC Inference</strong>, instrument it
with <strong>Langfuse</strong> so every reasoning step and every tool call becomes an inspectable node,
then debug <strong>two</strong> real failures from the trace tree — including the worst kind: a
confident, wrong answer that never throws an error. Every command, error, and screenshot
below is from a real run.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Built on a WEC Instance with Docker, against <strong>WEC Inference</strong> (<code>Qwen3.5-122B</code>) and a
self-hosted <strong>Langfuse</strong> from the
<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">observability tutorial</a>. The agent's tools
include the RAG service from the
<a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">RAG tutorial</a> — so this piece ties the
whole series together. All model calls stay on WEC; nothing leaves for a third-party API.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<!-- -->
<p>The dotted lines are the point: every step reports itself to Langfuse, so the agent's
<strong>reasoning</strong> and its <strong>actions</strong> land as separate, inspectable nodes.</p>
<p><strong>Prerequisites:</strong> a WEC Instance with Docker, a <strong>WEC Inference API key</strong>, a running
Langfuse (public + secret key, host URL), and a tool the agent can call — here the RAG
<code>/ask</code> service. All commands run in <code>~/agent-tracing</code>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--see-the-two-layers-no-tracing-yet">Step 1 — See the two layers (no tracing yet)<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-1--see-the-two-layers-no-tracing-yet" class="hash-link" aria-label="Direct link to Step 1 — See the two layers (no tracing yet)" title="Direct link to Step 1 — See the two layers (no tracing yet)" translate="no">​</a></h2>
<p><strong>Rule: never instrument before you've seen the raw behavior.</strong> So v1 does the minimum —
ask the model a question <em>with a tool definition</em>, and print what comes back. Set up the
workspace and the WEC key:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/agent-tracing </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/agent-tracing</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'^WEC_API_KEY='</span><span class="token plain"> ~/evolution-api/.env </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<p><code>agent.py</code> (v1):</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/agent.py (v1)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1/chat/completions"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC_API_KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MODEL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen3.5-122B"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">TOOLS </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"name"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"search_docs"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"description"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Search the WEC documentation for an answer to a question."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"parameters"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"object"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"properties"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"query"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"string"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"required"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"query"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">call_model</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">messages</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">WEC_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        headers</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"Authorization"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Bearer </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">WEC_API_KEY</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> MODEL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> messages</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tools"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> TOOLS</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tool_choice"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"auto"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">120</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"choices"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> __name__ </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__main__"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    msg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> call_model</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"How do I create a compute instance on WEC?"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"=== REASONING LAYER (what it thought) ==="</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"reasoning_content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"(none)"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"\n=== ACTION LAYER (what it decided to do) ==="</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> tc </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"tool: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">tc</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'function'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'name'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">  args: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">tc</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'function'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'arguments'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Run it in a container (same pattern as the rest of the series):</p>
<div class="language-docker codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/Dockerfile</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-docker codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">FROM python:3.12-slim</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WORKDIR /app</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RUN pip install --no-cache-dir requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">COPY agent.py .</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">CMD ["python", "agent.py"]</span><br></div></code></pre></div></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">agent</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">build</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">env_file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The model&amp;#39;s reasoning and its tool choice printed as two separate blocks" src="https://development-wec.wiline.com/docs/assets/images/agent-reasoning-action-56b6f9e7c5b8aef031cd06c04187e291.png" width="862" height="630" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> WEC Inference returns the two layers <em>in one response</em>: <code>reasoning_content</code>
(the thinking) and <code>tool_calls</code> (the decision). We don't have to infer the split — the API
hands it to us.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>WEC Inference supports tool calling — and exposes the reasoning</div><div class="admonitionContent_BuS1"><p><code>Qwen3.5-122B</code> returns <code>finish_reason: tool_calls</code> with a proper <code>tool_calls</code> array, <strong>plus
a <code>reasoning_content</code> field</strong> containing the model's chain of thought before it acts. That
reasoning field is exactly what makes the reasoning-vs-action split visible later.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--close-the-loop-execute-the-tool-get-the-answer">Step 2 — Close the loop: execute the tool, get the answer<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-2--close-the-loop-execute-the-tool-get-the-answer" class="hash-link" aria-label="Direct link to Step 2 — Close the loop: execute the tool, get the answer" title="Direct link to Step 2 — Close the loop: execute the tool, get the answer" translate="no">​</a></h2>
<p>v1 <em>decided</em> to search but didn't. Now actually call the tool (the RAG <code>/ask</code> service),
feed the result back, and let the model write the final answer. Add the tool + the loop:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/agent.py (v2, additions)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RAG_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://&lt;your-vm-ip&gt;:8000/ask"</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># the RAG service = our one tool</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">search_docs</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">query</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">RAG_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> query</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">120</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"answer"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">user_input</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    messages </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> user_input</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">while</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        msg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> call_model</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">messages</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"\n=== FINAL ANSWER ===\n"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        messages</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">append</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"assistant"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                         </span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> tc </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            args </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">tc</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"arguments"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"[reasoning] </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation">msg</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">get</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'reasoning_content'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation"> </span><span class="token string-interpolation interpolation keyword" style="color:#00009f">or</span><span class="token string-interpolation interpolation"> </span><span class="token string-interpolation interpolation string" style="color:#e3116c">''</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">strip</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">(</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">)</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">140]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"[action]    </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">tc</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'function'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'name'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">(</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">args</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">)"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> search_docs</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">args</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"query"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"[tool result] </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">result</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">140]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">..."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            messages</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">append</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tool"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tool_call_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> tc</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"id"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The full agent loop: reasoning, action, tool result, then the final grounded answer" src="https://development-wec.wiline.com/docs/assets/images/agent-loop-b09953660869437421c9222da3f9b135.png" width="862" height="783" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The complete loop — the model reasons, calls <code>search_docs</code>, gets the RAG
result, and writes a grounded answer. It works. But a working terminal tells you <em>nothing</em>
about whether the reasoning was sound — that's what we fix next.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--add-langfuse-the-layers-become-a-trace-tree">Step 3 — Add Langfuse: the layers become a trace tree<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-3--add-langfuse-the-layers-become-a-trace-tree" class="hash-link" aria-label="Direct link to Step 3 — Add Langfuse: the layers become a trace tree" title="Direct link to Step 3 — Add Langfuse: the layers become a trace tree" translate="no">​</a></h2>
<p>The terminal flattens everything into one stream. Langfuse turns each function into a
<strong>node</strong> so you can see the structure. Reuse the Langfuse keys from the observability
tutorial (same project as your RAG traces):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'^LANGFUSE_(PUBLIC_KEY|SECRET_KEY|HOST)='</span><span class="token plain"> ~/rag-service/.env </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> .env</span><br></div></code></pre></div></div>
<p>Add the SDK (<code>pip install ... langfuse</code>) and decorate three functions — that's the whole
instrumentation. <code>@observe</code> wraps a function as a span; <code>as_type="generation"</code> marks the
LLM calls so Langfuse captures model, tokens, and cost:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/agent.py (v3, decorators)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> observe</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> get_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@observe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">as_type</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"generation"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">call_model</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">messages</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    data </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    get_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">update_current_generation</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">model</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">MODEL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> usage_details</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"usage"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"choices"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@observe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">search_docs</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">query</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@observe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">user_input</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> __name__ </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__main__"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"How do I create a compute instance on WEC?"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    get_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># short-lived script: push traces before exit</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<p>Open Langfuse → <strong>Tracing → Traces</strong>. First thing you notice: there are <strong>two</strong> traces per
question.</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse traces list showing a run trace and an ask trace" src="https://development-wec.wiline.com/docs/assets/images/trace-list-3a0d985919db7d12160c4109ab85dede.png" width="1901" height="763" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> Distributed tracing, for free: your agent's <code>run</code> trace and the RAG service's
own <code>ask</code> trace are <em>separate</em> — each service instruments itself. Seeing both is how you
follow one request across service boundaries.</p>
<p>Open the newest <code>run</code>:</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse trace tree: run, two call_model generations, and the search_docs span" src="https://development-wec.wiline.com/docs/assets/images/trace-tree-green-c19c35d6b1ba42189355bdd0e79d96f8.png" width="1902" height="996" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The agent turn as a tree: <code>run</code> → <code>call_model</code> (decide) → <code>search_docs</code>
(act) → <code>call_model</code> (answer). Langfuse also captured <strong>latency</strong>, <strong>token usage</strong>
(801→306), and a correctness <strong>score</strong> — all for three decorators.</p>
<p>Click the first <code>call_model</code> and open its output:</p>
<p><span class="zoomImage__wrap"><img alt="The call_model node showing reasoning_content and tool_calls in Langfuse" src="https://development-wec.wiline.com/docs/assets/images/trace-reasoning-content-c5e9876f6e9a640d45e9f5f54c5594cc.png" width="1899" height="996" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> The reasoning layer, captured: <code>reasoning_content</code> ("<em>I should search the WEC
documentation…</em>") sits right next to the <code>tool_calls</code> it produced. This is the record you
debug against.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--break-it-the-loud-way-then-debug-from-the-trace">Step 4 — Break it (the loud way), then debug from the trace<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-4--break-it-the-loud-way-then-debug-from-the-trace" class="hash-link" aria-label="Direct link to Step 4 — Break it (the loud way), then debug from the trace" title="Direct link to Step 4 — Break it (the loud way), then debug from the trace" translate="no">​</a></h2>
<p>Plant a <strong>realistic</strong> bug: the RAG API expects <code>{"question": ...}</code>, but it's natural to
send <code>{"query": ...}</code> because the <em>tool's</em> parameter is named <code>query</code>. One wrong key:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/{"question": query}/{"query": query}/'</span><span class="token plain"> agent.py</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<p>It crashes:</p>
<p><span class="zoomImage__wrap"><img alt="The terminal showing a 422 HTTPError traceback" src="https://development-wec.wiline.com/docs/assets/images/agent-422-crash-af97e2fe3ae5709ec3fe6d5f859095a0.png" width="859" height="862" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The terminal gives you a <strong>stack trace</strong> — it tells you <em>where the code
broke</em>. It does <strong>not</strong> tell you whether the agent <em>thought</em> correctly. For that, go to the
trace.</p>
<p>Open the failed <code>run</code> and click the red <code>search_docs</code> span:</p>
<p><span class="zoomImage__wrap"><img alt="The broken trace: call_model green, search_docs red with a 422 error" src="https://development-wec.wiline.com/docs/assets/images/trace-error-span-ddca621bf15a8cc64c96371cd6a42e1a.png" width="1901" height="997" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The whole diagnosis in one picture: <code>call_model</code> is <strong>green</strong> (the reasoning
was right — it correctly chose to search), <code>search_docs</code> is <strong>red</strong>, died in <strong>0.04s</strong>, and
the panel shows the <code>422</code> plus the exact payload it sent. An instant failure is a
<em>rejection</em>, not a timeout — pointing straight at a bad request.</p>
<p>Fix the one line and confirm green:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/{"query": query}/{"question": query}/'</span><span class="token plain"> agent.py</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The fixed trace: all nodes green, final answer produced" src="https://development-wec.wiline.com/docs/assets/images/trace-fixed-green-c3b4677676dc40f861fca6dafe1594bb.png" width="1896" height="997" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> Back to green — <code>search_docs</code> succeeds and the final answer is produced. You
diagnosed <em>and</em> verified the fix from the trace.</p>
<p>That was the <strong>loud</strong> failure — it crashed, so you'd have caught it eventually even without
a trace. The dangerous one is next.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--a-second-tool-so-the-agent-has-to-choose">Step 5 — A second tool, so the agent has to <em>choose</em><a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-5--a-second-tool-so-the-agent-has-to-choose" class="hash-link" aria-label="Direct link to step-5--a-second-tool-so-the-agent-has-to-choose" title="Direct link to step-5--a-second-tool-so-the-agent-has-to-choose" translate="no">​</a></h2>
<p>Real agents have more than one tool, and the interesting decisions happen when the model
picks between them. Add a <code>get_pricing</code> tool alongside <code>search_docs</code>, and route calls
through a <strong>dispatch table</strong> keyed by tool name:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/agent-tracing/agent.py (v4, two tools + dispatch)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">TOOLS </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"name"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"search_docs"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"description"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Search the WEC documentation for how-to and setup questions."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"parameters"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"object"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"properties"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"query"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"string"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"required"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"query"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"name"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"get_pricing"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"description"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Get the price of a WEC resource (e.g. 'compute instance', 'block storage', 'inference')."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token string" style="color:#e3116c">"parameters"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"object"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"properties"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"resource"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"string"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"required"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"resource"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">PRICES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"compute instance"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"$0.03 / vCPU-hour"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"block storage"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"$0.10 / GB-month"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"inference"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"$0.50 per 1M tokens"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@observe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">get_pricing</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">resource</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> PRICES</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">resource</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"No pricing found for '</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">resource</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">'."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">TOOL_FUNCS </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"search_docs"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> search_docs</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"get_pricing"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> get_pricing</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># in run(), dispatch by the tool the model actually chose:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> tc </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"tool_calls"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    name </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> tc</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"name"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    args </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">tc</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"function"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"arguments"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"[action] </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">name</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">(</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">args</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">)"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">TOOL_FUNCS</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">name</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">**</span><span class="token plain">args</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    messages</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">append</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tool"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"tool_call_id"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> tc</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"id"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Ask a <strong>pricing</strong> question (<code>run("How much does a compute instance cost on WEC?")</code>) and run
it. The agent should pick <code>get_pricing</code>, not <code>search_docs</code>:</p>
<p><span class="zoomImage__wrap"><img alt="Langfuse trace where the agent correctly chose get_pricing" src="https://development-wec.wiline.com/docs/assets/images/trace-two-tools-ba891f9c0ec4d99aea2e499dcbba3189.png" width="1899" height="995" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> Genuine tool selection: given two tools, the agent chose <code>get_pricing</code> and
answered <strong>$0.03/vCPU-hour</strong>, Correctness 1.00. The choice itself is now a thing you can
see in the trace.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--the-silent-failure-why-tracing-earns-its-keep">Step 6 — The silent failure (why tracing earns its keep)<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#step-6--the-silent-failure-why-tracing-earns-its-keep" class="hash-link" aria-label="Direct link to Step 6 — The silent failure (why tracing earns its keep)" title="Direct link to Step 6 — The silent failure (why tracing earns its keep)" translate="no">​</a></h2>
<p>Now a bug that <strong>doesn't crash</strong>. Someone refactors the dispatch and hardcodes the old
single tool, forgetting the new one — so every call runs <code>search_docs</code> no matter what the
model chose:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/result = str(TOOL_FUNCS\[name\](\*\*args))/result = str(search_docs(list(args.values())[0]))  # BUG/'</span><span class="token plain"> agent.py</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> agent</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">A passing run — with a completely wrong answer</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[action] get_pricing({'resource': 'compute instance'})</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">=== FINAL ANSWER ===</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Based on the pricing information available, the highest cost compute instance category on</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC is $1,586.77. ...</span><br></div></code></pre></div></div>
<p>Read that carefully. The model <strong>chose <code>get_pricing</code></strong> (correct). The answer is confident,
detailed — and <strong>completely wrong</strong> (the real price is $0.03/vCPU-hour). There is <strong>no
error, no traceback.</strong> A log would look perfectly healthy. This is the failure mode that
ships to production and quietly lies to users.</p>
<p>The trace is the only thing that catches it. Open the <code>run</code>:</p>
<p><span class="zoomImage__wrap"><img alt="The silent-failure trace: get_pricing chosen but search_docs executed, Correctness 0.00" src="https://development-wec.wiline.com/docs/assets/images/trace-silent-failure-162a3ce9394bad7caca0e908be683b67.png" width="1901" height="996" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 10.</strong> Three tells a log can't show: (1) the executed span is <strong><code>search_docs</code></strong> —
there's <strong>no <code>get_pricing</code> span</strong>, even though the model chose it: <em>decision ≠ action</em>;
(2) the reasoning node scored <strong>Correctness 0.00</strong> — the LLM-judge auto-flagged the wrong
answer; (3) the output shows the model <em>confused</em> by a tool result that didn't match what
it asked for. Fix the dispatch back to <code>TOOL_FUNCS[name](**args)</code> and the price is correct
again.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You instrumented a tool-calling agent so its reasoning and its actions are separate,
inspectable trace nodes — and debugged <strong>two</strong> real failures from the trace: the loud
crash <em>and</em> the silent, confident-but-wrong answer that no stack trace would ever reveal.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<ul>
<li class=""><strong>No trace appears.</strong> A short script exits before the SDK flushes. Call
<code>get_client().flush()</code> before the process ends (already in v3).</li>
<li class=""><strong><code>search_docs</code> span missing.</strong> Only <code>@observe</code>-decorated functions become nodes —
decorate the function, not the call site.</li>
<li class=""><strong>Generation shows no tokens/cost.</strong> Pass <code>usage_details=data.get("usage")</code> via
<code>update_current_generation</code>; without it Langfuse can't compute cost.</li>
<li class=""><strong>Agent and tool traces look disconnected.</strong> Expected — each service traces itself. Link
them later with trace propagation if you need a single cross-service view.</li>
<li class=""><strong>A silent wrong answer with no error.</strong> Compare the model's <code>tool_calls</code> (what it chose)
against the executed spans (what actually ran). A mismatch is a dispatch/routing bug — and
a correctness score on the trace will flag it automatically.</li>
</ul>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Trace &amp; debug agent tool calls</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You can now see an agent think, act, and <em>fail quietly</em> — and catch it. Point the same
instrumentation at a real multi-step agent, add automated correctness scoring on every
trace, and you have production observability that surfaces silent failures before your users
do.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/agent-tracing </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose down </span><span class="token parameter variable" style="color:#36acaa">--rmi</span><span class="token plain"> </span><span class="token builtin class-name">local</span><br></div></code></pre></div></div>]]></content:encoded>
            <category>ai</category>
            <category>evals</category>
            <category>observability</category>
            <category>langfuse</category>
            <category>agents</category>
            <category>tracing</category>
            <category>inference</category>
        </item>
        <item>
            <title><![CDATA[Build a WhatsApp AI assistant from scratch with Evolution and the WEC API]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/</guid>
            <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Self-host a programmable WhatsApp gateway (Evolution API) and write the ~50-line bridge that turns it into an AI assistant — webhook in, LLM out, reply back. Every gotcha is real: the image that moved publishers, the Baileys version loop, the loop guard, group spam, WhatsApp's LID addressing, and the delivery wall nobody warns you about.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/whatsapp_logo-cc34546fcff90d36128c7230c48c496f.png" alt="WhatsApp"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__evolution" src="https://development-wec.wiline.com/docs/assets/images/0_hT2ECga7Z67dEbYz-62f9ab60d339ae8868fee46475e505bc.png" alt="Evolution"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 2 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->2</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->2<!-- --> earned</span></div><div class="skillTracker__series">WhatsApp automation on WEC</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">Self-host a WhatsApp AI bridge</span></span></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Production delivery via Cloud API</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Most "self-host a WhatsApp AI" guides stop at "the container started." This one goes all
the way: you deploy a real, programmable WhatsApp gateway (<strong>Evolution API</strong>), then
<strong>write the bridge yourself</strong> — the ~50 lines that turn an incoming message into an LLM
answer and send it back. That bridge (webhook → model → reply) is the reusable pattern
behind <em>every</em> chat-AI integration: SMS, Slack, Telegram, voice — swap the channel, the
shape is identical.</p>
<p>And because this is a real build, we hit — and fix — every gotcha: an image that moved
publishers, a Baileys version loop, an infinite reply loop, group-chat spam, WhatsApp's
new <strong>LID</strong> addressing, and a genuine <strong>delivery wall</strong> that most tutorials pretend
doesn't exist. Every command, error, and output below is from an actual run.</p>
<!-- -->
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Read this before you use your personal number</div><div class="admonitionContent_BuS1"><p>Evolution links to a WhatsApp number as a <strong>companion device</strong> (like WhatsApp Web) — the
bot acts <em>as that account</em> and can read every DM and group it receives. For a demo on a
number you control it's fine; for anything real, use a <strong>dedicated number</strong>. And see the
<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#the-delivery-wall" class="">delivery limitation</a> at the end before you build on this in
production.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<!-- -->
<p>Four containers: Evolution + its Postgres + Redis, and the <strong>bridge</strong> you'll write. The
bridge is the whole point — everything else is off-the-shelf.</p>
<p><strong>Prerequisites:</strong> a WEC Instance with Docker + Compose, a <strong>WEC Inference API key</strong>
(<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>), a WhatsApp number for
the demo, and ~2 GB free disk.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--deploy-the-evolution-stack">Step 1 — Deploy the Evolution stack<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-1--deploy-the-evolution-stack" class="hash-link" aria-label="Direct link to Step 1 — Deploy the Evolution stack" title="Direct link to Step 1 — Deploy the Evolution stack" translate="no">​</a></h2>
<p>Evolution needs its own Postgres and Redis. We give it a dedicated set on an internal
network and expose only the API port (8080). <code>docker-compose.yml</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/evolution-api/docker-compose.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">evolution-api</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> evoapicloud/evolution</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">api</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">v2.2.3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">ports</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"8080:8080"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">env_file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> evolution_instances</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/evolution/instances</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">depends_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">evolution</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">postgres</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> evolution</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">redis</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">logging</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">driver</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">file</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">options</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">max-size</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"50m"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">max-file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"3"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">evolution-postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> postgres</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">17</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_USER</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> evolution</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_PASSWORD</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">POSTGRES_PASSWORD</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">POSTGRES_DB</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> evolution</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> evolution_pgdata</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/var/lib/postgresql/data</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">evolution-redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">7</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> evolution_redis</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/data</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">evolution_instances</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">evolution_pgdata</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  evolution_redis</span><span class="token punctuation" style="color:#393A34">:</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Log rotation from day one</div><div class="admonitionContent_BuS1"><p>The <code>logging:</code> block caps container logs at 3×50 MB. Skip it and a chatty container can
fill the disk and take the box down — a lesson learned the hard way on this VM. Set it
before you need it.</p></div></div>
<p>The <code>.env</code> (generated secrets, connection URIs, and — importantly — a <strong>pinned Baileys
WhatsApp-Web version</strong>):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Generate the .env</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/evolution-api </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">EOF</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">AUTHENTICATION_API_KEY=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-hex</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">24</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">SERVER_URL=http://&lt;your-vm-ip&gt;:8080</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">POSTGRES_PASSWORD=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-hex</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">16</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">DATABASE_ENABLED=true</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">DATABASE_PROVIDER=postgresql</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">DATABASE_CONNECTION_URI=postgresql://evolution:\</span><span class="token string variable" style="color:#36acaa">${POSTGRES_PASSWORD}</span><span class="token string" style="color:#e3116c">@evolution-postgres:5432/evolution?schema=public</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CACHE_REDIS_ENABLED=true</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CACHE_REDIS_URI=redis://evolution-redis:6379/0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CACHE_REDIS_PREFIX_KEY=evolution</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CACHE_LOCAL_ENABLED=false</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">QRCODE_LIMIT=30</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p>Bring it up:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">15</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://localhost:8080 </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "status": 200,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "message": "Welcome to the Evolution API, it is working!",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "version": "2.2.3",</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Evolution API responding on port 8080 with the welcome payload" src="https://development-wec.wiline.com/docs/assets/images/wa-evolution-up-680a7f5fe9e4a450cacf33f1df5ed6f7.png" width="628" height="182" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The stack is up: Evolution answers on <code>:8080</code> with its version and status.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Two real gotchas pulling the image</div><div class="admonitionContent_BuS1"><p><strong>(1) The publisher moved.</strong> The image most guides reference — <code>atendai/evolution-api</code> —
now returns <em>"pull access denied / repository does not exist."</em> The current image is
<strong><code>evoapicloud/evolution-api</code></strong>. The old repo's tag list still resolves, which sends you
down a rabbit hole; confirm with a fresh pull of <code>hello-world</code> that it's not rate-limiting.
<strong>(2) Pin a real tag.</strong> <code>v2.1.1</code> (from a popular blog) was never published — check
<code>https://hub.docker.com/v2/repositories/evoapicloud/evolution-api/tags</code> and pin one that
exists (we use <code>v2.2.3</code>).</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--create-an-instance-and-link-whatsapp">Step 2 — Create an instance and link WhatsApp<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-2--create-an-instance-and-link-whatsapp" class="hash-link" aria-label="Direct link to Step 2 — Create an instance and link WhatsApp" title="Direct link to Step 2 — Create an instance and link WhatsApp" translate="no">​</a></h2>
<p>Set your API key as a shell var (every request needs it in an <code>apikey:</code> header):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">APIKEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">grep</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'^AUTHENTICATION_API_KEY='</span><span class="token variable" style="color:#36acaa"> .env </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable function" style="color:#d73a49">cut</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-d</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-f2</span><span class="token variable" style="color:#36acaa">)</span><br></div></code></pre></div></div>
<p>Create the instance:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST http://localhost:8080/instance/create </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"apikey: </span><span class="token string variable" style="color:#36acaa">$APIKEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"instanceName":"wec-demo","integration":"WHATSAPP-BAILEYS","qrcode":true}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.instance'</span><br></div></code></pre></div></div>
<p>Then open the built-in Manager at <code>http://&lt;your-vm-ip&gt;:8080/manager</code>, enter your server
URL + API key, click <strong>wec-demo</strong>, and scan the QR from <strong>WhatsApp → Settings → Linked
Devices → Link a Device</strong>. Confirm it's connected:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> http://localhost:8080/instance/fetchInstances </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"apikey: </span><span class="token string variable" style="color:#36acaa">$APIKEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.[] | {name, connectionStatus}'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{ "name": "wec-demo", "connectionStatus": "open" }</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Evolution Manager showing the wec-demo instance connected" src="https://development-wec.wiline.com/docs/assets/images/wa-manager-connected-d91fda82b98c6238d0d747b47636645b.png" width="2834" height="1468" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The Evolution Manager dashboard once the instance links: <code>Connected</code>, with live contact, chat, and message counts.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The Baileys version loop — the #1 self-host failure</div><div class="admonitionContent_BuS1"><p>If the instance never leaves <code>connecting</code> and the logs show <code>ChannelStartupService</code>
re-initializing every few seconds with a <code>Baileys version env: 2,3000,...</code> line, the
pinned WhatsApp-Web version is <strong>stale</strong> and WhatsApp rejects the handshake before a QR is
ever generated. Fetch the current version and pin it, then recreate:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/baileys-version.json"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># {"version":[2,3000,1035194821]}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"CONFIG_SESSION_PHONE_VERSION=2.3000.1035194821"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> --force-recreate evolution-api</span><br></div></code></pre></div></div></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-bridge-v1-see-the-webhook">Step 3 — The bridge, v1: <em>see</em> the webhook<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-3--the-bridge-v1-see-the-webhook" class="hash-link" aria-label="Direct link to step-3--the-bridge-v1-see-the-webhook" title="Direct link to step-3--the-bridge-v1-see-the-webhook" translate="no">​</a></h2>
<p>Now the part you actually write. <strong>Rule: never wire logic before you've seen the real
data.</strong> So v1 does nothing but print whatever Evolution sends.</p>
<p>The bridge runs as another container in the same Compose, so Evolution reaches it <strong>by
name</strong> (<code>http://bridge:8090</code>) and the bridge reaches Evolution at
<code>http://evolution-api:8080</code>. Create <code>bridge/main.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/evolution-api/bridge/main.py (v1)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastapi </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> FastAPI</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Request</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">app </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> FastAPI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@app</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"/webhook"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">webhook</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">request</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Request</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    data </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> request</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"=== WEBHOOK RECEIVED ==="</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"received"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span><code>flush=True</code> matters</div><div class="admonitionContent_BuS1"><p>Without it, <code>print</code> output is buffered and never shows in <code>docker compose logs</code> — you'll
stare at an empty log convinced it's broken.</p></div></div>
<p><code>bridge/Dockerfile</code>:</p>
<div class="language-docker codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/evolution-api/bridge/Dockerfile</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-docker codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">FROM python:3.12-slim</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WORKDIR /app</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RUN pip install --no-cache-dir fastapi uvicorn requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">COPY main.py .</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]</span><br></div></code></pre></div></div>
<p>Add the service to <code>docker-compose.yml</code> (inside <code>services:</code>), then build it:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml (add under services:)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">bridge</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">build</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ./bridge</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">restart</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> unless</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">env_file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">depends_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">evolution</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">api</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">logging</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">driver</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">file</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">options</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">max-size</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"10m"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">max-file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"3"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> bridge</span><br></div></code></pre></div></div>
<p>Now <strong>register the webhook</strong> so Evolution POSTs messages to the bridge:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST </span><span class="token string" style="color:#e3116c">"http://localhost:8080/webhook/set/wec-demo"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"apikey: </span><span class="token string variable" style="color:#36acaa">$APIKEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"webhook":{"enabled":true,"url":"http://bridge:8090/webhook","webhookByEvents":false,"events":["MESSAGES_UPSERT"]}}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.enabled, .url'</span><br></div></code></pre></div></div>
<p>Tail the logs (<code>docker compose logs -f bridge</code>) and send yourself a WhatsApp message. The
real payload appears:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">A real MESSAGES_UPSERT webhook</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{'event': 'messages.upsert', 'instance': 'wec-demo',</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 'data': {'key': {'remoteJid': '123456789012345@lid', 'fromMe': False, 'id': '...'},</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          'pushName': 'Test Contact',</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          'message': {'conversation': 'Oii'},</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          'messageType': 'conversation'},</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 'sender': '5511988887777@s.whatsapp.net', ...}</span><br></div></code></pre></div></div>
<p>Three things we now <em>know</em> — and every one drives the code next:</p>
<ul>
<li class=""><strong>text</strong> is at <code>data.message.conversation</code> (plain) or <code>data.message.extendedTextMessage.text</code> (quoted)</li>
<li class=""><strong><code>data.key.fromMe</code></strong> — <code>True</code> for our own messages (the loop guard)</li>
<li class=""><strong><code>remoteJid</code> ends in <code>@lid</code></strong> — WhatsApp's privacy identifier, <em>not</em> a phone number (this one's a saga; Step 5)</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--v2-read-think-reply-with-the-loop-guard">Step 4 — v2: read, think, reply (with the loop guard)<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-4--v2-read-think-reply-with-the-loop-guard" class="hash-link" aria-label="Direct link to Step 4 — v2: read, think, reply (with the loop guard)" title="Direct link to Step 4 — v2: read, think, reply (with the loop guard)" translate="no">​</a></h2>
<p>Give the bridge the WEC Inference key so it can call the model:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"WEC_API_KEY=&lt;your-wec-inference-key&gt;"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> ~/evolution-api/.env</span><br></div></code></pre></div></div>
<p>Now the logic — <code>main.py</code> v2:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">~/evolution-api/bridge/main.py (v2)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> fastapi </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> FastAPI</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> Request</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">app </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> FastAPI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">EVOLUTION_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://evolution-api:8080"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">INSTANCE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"wec-demo"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">EVOLUTION_APIKEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"AUTHENTICATION_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC_API_KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1/chat/completions"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MODEL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ask_llm</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">text</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">WEC_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        headers</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"Authorization"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"Bearer </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">WEC_API_KEY</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> MODEL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"messages"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">60</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"choices"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">send_whatsapp</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">number</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">EVOLUTION_URL</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/message/sendText/</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">INSTANCE</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        headers</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"apikey"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> EVOLUTION_APIKEY</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"application/json"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"number"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> number</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"text"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">60</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"SEND -&gt; </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">r</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">status_code</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@app</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"/webhook"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">webhook</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">request</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Request</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    data </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">await</span><span class="token plain"> request</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"data"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    key </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"key"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> key</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"fromMe"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">                     </span><span class="token comment" style="color:#999988;font-style:italic"># (1) LOOP GUARD</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"skipped"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"fromMe"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    msg </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"message"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">             </span><span class="token comment" style="color:#999988;font-style:italic"># (2) extract text</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    text </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"conversation"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"extendedTextMessage"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"text"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"skipped"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"no text"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    to </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> key</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"remoteJid"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"IN  </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">to</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">text</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    answer </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> ask_llm</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">text</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                     </span><span class="token comment" style="color:#999988;font-style:italic"># (3) think</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    send_whatsapp</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">to</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> answer</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                  </span><span class="token comment" style="color:#999988;font-style:italic"># (4) reply</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"OUT </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">to</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">answer</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">:</span><span class="token string-interpolation interpolation format-spec">60]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">..."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> flush</span><span class="token operator" style="color:#393A34">=</span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"ok"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">True</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>The one line beginners always miss is <strong>(1) the loop guard</strong>. When the bridge sends a
reply, that outgoing message fires <em>another</em> <code>MESSAGES_UPSERT</code> webhook with <code>fromMe: true</code>
— without the guard, the bridge answers itself, forever. Rebuild:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"> bridge</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The bridge logging IN and OUT for a live DM" src="https://development-wec.wiline.com/docs/assets/images/wa-bridge-logs-53cb3cf64a3d62d69794ee3a0bf3742b.png" width="768" height="205" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> v2 in action: a DM comes in, the model answers, the reply goes out.</p>
<p>It answers DMs now. But watch what happens next.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--the-two-real-walls-group-spam-then-lid">Step 5 — The two real walls: group spam, then LID<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-5--the-two-real-walls-group-spam-then-lid" class="hash-link" aria-label="Direct link to Step 5 — The two real walls: group spam, then LID" title="Direct link to Step 5 — The two real walls: group spam, then LID" translate="no">​</a></h2>
<p>Tail the logs with the bot live and you'll see it try to answer <strong>every group you're in</strong>,
including promo/spam groups, and Evolution <strong>times out</strong> sending to them — flooding errors:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Groups flood the bridge</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">IN  120363040812472138@g.us: *ALÔ CORREDORES 🏃 ... pechin.co/135449*</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">requests.exceptions.ReadTimeout: HTTPConnectionPool(host='evolution-api', port=8080): Read timed out.</span><br></div></code></pre></div></div>
<p>A companion account receives <strong>everything</strong>. Fix: answer <strong>DMs only</strong> — groups end in
<code>@g.us</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    to </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> key</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"remoteJid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> to</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">endswith</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"@g.us"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"broadcast"</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> to</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># DMs only</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"skipped"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"not a DM"</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Now the subtler wall. With groups filtered, a real DM comes in — but the reply gets
<strong>rejected</strong>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">SEND -&gt; 400</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{"status":400,"error":"Bad Request","response":{"message":[</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  {"exists":false,"jid":"123456789012345@lid","name":"Test Contact","number":"123456789012345@lid"}]}}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Evolution rejecting a reply to an @lid address with exists" src="https://development-wec.wiline.com/docs/assets/images/wa-lid-400-84cc0430abac7bdd5aa6d29cf1c100b5.png" width="863" height="218" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The LID wall in the logs: the incoming DM arrives as <code>@lid</code>, and the reply is rejected with <code>exists:false</code>.</p>
<p><code>exists: false</code>. This is <strong>WhatsApp's LID addressing</strong> (a 2025 privacy change): incoming
DMs arrive with an <code>@lid</code> identifier, and <strong>you cannot send back to an <code>@lid</code></strong> — Evolution
needs the real <code>@s.whatsapp.net</code> phone JID. Evolution's contact store has both, though:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST </span><span class="token string" style="color:#e3116c">"http://localhost:8080/chat/findContacts/wec-demo"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"apikey: </span><span class="token string variable" style="color:#36acaa">$APIKEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'[.[] | select(.pushName=="Test Contact")]'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output — same person, two identities</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[ { "remoteJid": "123456789012345@lid",         "pushName": "Test Contact" },</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  { "remoteJid": "5511999999999@s.whatsapp.net", "pushName": "Test Contact" } ]</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="findContacts returning the LID and the phone JID for one contact" src="https://development-wec.wiline.com/docs/assets/images/wa-findcontacts-275c01bee94ec0da990f2cdd0ec6acf2.png" width="862" height="429" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> Evolution stores both identities for the same person — the <code>@lid</code> and the real phone JID. That is the key to resolving the reply address.</p>
<p>So we <strong>resolve the <code>@lid</code> to the phone number</strong> before replying. Add a resolver and use it:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">resolve_number</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">remote_jid</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> push_name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> remote_jid</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">endswith</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"@s.whatsapp.net"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> remote_jid</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"@"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic"># LID: find the contact's real number by matching the name</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    contacts </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">EVOLUTION_URL</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/chat/findContacts/</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">INSTANCE</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        headers</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"apikey"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> EVOLUTION_APIKEY</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"application/json"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">30</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> c </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> contacts</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"pushName"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> push_name </span><span class="token keyword" style="color:#00009f">and</span><span class="token plain"> c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"remoteJid"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">endswith</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"@s.whatsapp.net"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> c</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"remoteJid"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">split</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"@"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><br></div></code></pre></div></div>
<p>In the handler, resolve before sending:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    number </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> resolve_number</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">to</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"pushName"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> number</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"skipped"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"unresolved lid"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    send_whatsapp</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">number</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> answer</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Now <code>SEND -&gt; 201</code>. (Matching by name is a heuristic — two contacts sharing a name would
collide; production keeps a proper LID→phone map. But it works, and it's the honest state
of Evolution's LID support in v2.2.3.)</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--speak-whatsapp-not-markdown">Step 6 — Speak WhatsApp, not Markdown<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#step-6--speak-whatsapp-not-markdown" class="hash-link" aria-label="Direct link to Step 6 — Speak WhatsApp, not Markdown" title="Direct link to Step 6 — Speak WhatsApp, not Markdown" translate="no">​</a></h2>
<p>LLMs emit <strong>Markdown</strong> (<code>**bold**</code>, <code>[text](url)</code>), but WhatsApp has its <em>own</em> formatting:
bold is <code>*single asterisk*</code>, and Markdown links don't render. So <code>**Compute**</code> shows up
with literal asterisks. Translate the model's output before sending, and append the RAG's
sources as plain URLs:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> re</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">to_whatsapp</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">md</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    md </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> re</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sub</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">r"\*\*(.+?)\*\*"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">r"*\1*"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> md</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                        </span><span class="token comment" style="color:#999988;font-style:italic"># **bold** -&gt; *bold*</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    md </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> re</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sub</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">r"\[([^\]]+)\]\((https?://[^)]+)\)"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">r"\1 (\2)"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> md</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic"># [t](url) -&gt; t (url)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    md </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> re</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sub</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">r"(?m)^#{1,6}\s*"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> md</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                            </span><span class="token comment" style="color:#999988;font-style:italic"># drop # headings</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> md</span><br></div></code></pre></div></div>
<p>Verified against a real answer — exactly what WhatsApp will render:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Formatted output (deterministic, no delivery needed)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">To create a compute instance on WiLine Edge Cloud (WEC), follow these steps:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">1. Log in to the WiLine Edge Cloud.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">2. In the sidebar, click *Compute*.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">3. Select *Instances*.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">4. Click the "Launch Virtual Machine" wizard to begin the deployment process.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">_Sources:_</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">https://wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The Markdown-to-WhatsApp formatted answer" src="https://development-wec.wiline.com/docs/assets/images/wa-formatted-output-766cfd620ca72685d8f37110da283d56.png" width="861" height="444" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The model output after to_whatsapp(): single-asterisk bold, plain links, sources appended.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="level-up--point-the-bridge-at-your-docs-rag">Level up — point the bridge at your docs (RAG)<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#level-up--point-the-bridge-at-your-docs-rag" class="hash-link" aria-label="Direct link to Level up — point the bridge at your docs (RAG)" title="Direct link to Level up — point the bridge at your docs (RAG)" translate="no">​</a></h2>
<p>Everything so far uses the raw model, so a WEC question gets a <em>general</em> answer. To make it
answer from <strong>your documentation</strong>, swap <code>ask_llm</code> to call the RAG service from the
<a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">evals series</a> — <strong>one function</strong>, nothing
else changes:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">RAG_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://&lt;your-vm-ip&gt;:8000/ask"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ask_llm</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">text</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">RAG_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> text</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">120</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    d </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    answer </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> to_whatsapp</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">d</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"answer"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    sources </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> d</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"sources"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> sources</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        answer </span><span class="token operator" style="color:#393A34">+=</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"\n\n_Sources:_\n"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"\n"</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sources</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> answer</span><br></div></code></pre></div></div>
<p>That's the payoff of the bridge pattern: <strong>swap the brain, keep the plumbing.</strong> Now WhatsApp
answers WEC questions from the real docs, with sources:</p>
<p><span class="zoomImage__wrap"><img alt="WhatsApp delivering a WEC-docs-grounded answer" src="https://development-wec.wiline.com/docs/assets/images/wa-bridge-reply-bf0eaa9a06721ee1396cbf44fd96ae3a.png" width="1316" height="1729" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> A real message in → the bridge → RAG over the WEC docs → a grounded answer
delivered on WhatsApp, sources included.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You self-hosted a programmable WhatsApp gateway and wrote the bridge that turns it into
an AI assistant — webhook in, model out, reply back — grounded in your own docs.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-delivery-wall">The delivery wall<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#the-delivery-wall" class="hash-link" aria-label="Direct link to The delivery wall" title="Direct link to The delivery wall" translate="no">​</a></h2>
<p>Here's the part other tutorials won't tell you. Even with <code>SEND -&gt; 201</code>, replies to a
<strong>LID-migrated account</strong> frequently arrive as <strong>"Waiting for this message"</strong> on the
recipient's phone — WhatsApp accepted the ciphertext but no device can decrypt it. It's a
known <strong>Baileys companion-device</strong> limitation, and WhatsApp's LID rollout makes it worse.</p>
<p>What we observed, honestly:</p>
<ul>
<li class="">A <strong>fresh re-link</strong> (log out the device, scan a new QR) buys a <strong>short window</strong> where
delivery works cleanly.</li>
<li class="">After a while the session degrades and <code>"Waiting for this message"</code> returns — even though
every send still reports <code>201</code>.</li>
</ul>
<p>So the bridge is correct end-to-end; the <em>unofficial Baileys transport</em> is the weak link.
If you're building this for real:</p>
<ul>
<li class=""><strong>Use a dedicated, non-LID number</strong> — the issue is tied to LID-migrated accounts.</li>
<li class="">For production-grade reliability, use the <strong>official WhatsApp Cloud API</strong>, or —
if your goal is an AI agent rather than a programmable gateway — <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/">OpenClaw's native
WhatsApp channel</a></strong>, which handles the session
for you.</li>
</ul>
<p>Evolution shines for <strong>outbound automation to numbers you control</strong> (notifications, alerts,
flows). As a two-way AI assistant on a personal LID account, treat delivery as best-effort.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-recap">Troubleshooting recap<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#troubleshooting-recap" class="hash-link" aria-label="Direct link to Troubleshooting recap" title="Direct link to Troubleshooting recap" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>pull access denied</code> on <code>atendai/evolution-api</code></strong> → image moved to <code>evoapicloud/evolution-api</code>.</li>
<li class=""><strong>Instance stuck <code>connecting</code>, re-init loop</strong> → stale Baileys version; pin <code>CONFIG_SESSION_PHONE_VERSION</code>.</li>
<li class=""><strong>Bridge answers itself forever</strong> → missing <code>fromMe</code> loop guard.</li>
<li class=""><strong>Flood of group messages / send timeouts</strong> → filter <code>@g.us</code>; answer DMs only.</li>
<li class=""><strong><code>SEND -&gt; 400 exists:false ...@lid</code></strong> → resolve the LID to the phone JID via <code>findContacts</code>.</li>
<li class=""><strong><code>**asterisks**</code> in replies</strong> → translate Markdown to WhatsApp formatting.</li>
<li class=""><strong>"Waiting for this message"</strong> → Baileys/LID decryption wall; fresh re-link is a temporary
window; use a non-LID number / Cloud API for production.</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/evolution-api </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose down          </span><span class="token comment" style="color:#999988;font-style:italic"># add -v to also wipe the DB/session</span><br></div></code></pre></div></div>
<p>Unlink the device in <strong>WhatsApp → Linked Devices</strong> if you're done.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Self-host a WhatsApp AI bridge</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/whatsapp-ai-assistant-evolution-api/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You built the reusable bridge pattern — webhook → resolve → model → reply. Point it at a
different brain, a different channel, or add tools. If you want an AI agent on WhatsApp
without the Baileys caveats, the <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/">OpenClaw native WhatsApp
channel</a></strong> is the managed path; for docs-grounded
answers, the <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">evals &amp; observability series</a></strong>
builds the RAG service this tutorial plugs into.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>whatsapp</category>
            <category>evolution-api</category>
            <category>docker</category>
            <category>inference</category>
        </item>
        <item>
            <title><![CDATA[Add a WhatsApp channel to OpenClaw]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/</guid>
            <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Put your self-hosted agent on WhatsApp. Add the channel, trust the plugin, scan a QR — and understand why a companion link makes the agent act as your account. Every command and error from a real run.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/whatsapp_logo-cc34546fcff90d36128c7230c48c496f.png" alt="WhatsApp"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Deploy your own AI assistant</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Real HTTPS + auth</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Chat from Telegram</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Private mesh access</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">Run it on WEC models</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">🏆</span><span class="skillTracker__skill" data-state="current">Chat from WhatsApp</span></span></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Telegram (<a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Part 3</a>) gave your agent a <strong>bot</strong>.
WhatsApp gives it a <strong>phone line</strong> — the app ~3 billion people already use, reachable with
zero friction. One catch worth understanding up front: WhatsApp has no bot account, so
OpenClaw links to a real number as a <strong>companion device</strong> (like WhatsApp Web) and the
agent acts <em>as that account</em>. Every command and error below is from a real run.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues from Parts 1–4 —
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">deploy</a>,
<a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">HTTPS</a>,
<a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Telegram</a>,
<a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">NetBird</a> — on OpenClaw <strong>2026.7.1</strong>. WhatsApp links
by <strong>QR</strong>, no public webhook or domain needed.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<p>Anyone messages the number; the gateway — linked as a companion device — runs it through
your model and replies <strong>from the number itself</strong>. No inbound ports, no bot account.</p>
<!-- -->
<p><strong>Prerequisites:</strong> Parts 1–4 done (OpenClaw in <code>~/openclaw</code>), a WhatsApp account on your
phone, ~1 GB free disk. All commands run in <code>~/openclaw</code>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--add-the-whatsapp-channel">Step 1 — Add the WhatsApp channel<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#step-1--add-the-whatsapp-channel" class="hash-link" aria-label="Direct link to Step 1 — Add the WhatsApp channel" title="Direct link to Step 1 — Add the WhatsApp channel" translate="no">​</a></h2>
<p>OpenClaw treats messaging apps as <em>channels</em>; WhatsApp is a first-class one:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels </span><span class="token function" style="color:#d73a49">add</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--channel</span><span class="token plain"> whatsapp</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Installed WhatsApp plugin</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Added WhatsApp account "default".</span><br></div></code></pre></div></div>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary><b>Got <code>requires plugin API &gt;=2026.7.1</code>?</b> You're on an older OpenClaw — upgrade first.</summary><div><div class="collapsibleContent_i85q"><p>The WhatsApp channel is a plugin gated to newer runtimes:</p><p><span class="zoomImage__wrap"><img alt="The version-wall error on OpenClaw 2026.6.8" src="https://development-wec.wiline.com/docs/assets/images/wa-version-wall-db236509ca6e250b19147327473e0932.png" width="861" height="592" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The version wall — the WhatsApp plugin needs OpenClaw ≥2026.7.1.</p><p>Back up, pull, recreate, and confirm your existing channels survived (<code>:latest</code> does
<strong>not</strong> auto-update — you must pull deliberately):</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cp</span><span class="token plain"> ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak-</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">date</span><span class="token variable" style="color:#36acaa"> +%Y%m%d</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose pull openclaw-gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli </span><span class="token parameter variable" style="color:#36acaa">--version</span><span class="token plain">          </span><span class="token comment" style="color:#999988;font-style:italic"># expect 2026.7.1+</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> --force-recreate openclaw-gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels status    </span><span class="token comment" style="color:#999988;font-style:italic"># Telegram should still be connected</span><br></div></code></pre></div></div><p><span class="zoomImage__wrap"><img alt="The pulled image reports 2026.7.1" src="https://development-wec.wiline.com/docs/assets/images/wa-version-check-b1b98be0f2990d46e5686130b2722d3b.png" width="695" height="156" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> After pulling, the image reports 2026.7.1 — the requirement is met.</p><p>Then re-run the <code>channels add</code> above.</p></div></div></details>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--trust-the-plugin">Step 2 — Trust the plugin<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#step-2--trust-the-plugin" class="hash-link" aria-label="Direct link to Step 2 — Trust the plugin" title="Direct link to Step 2 — Trust the plugin" translate="no">​</a></h2>
<p>Check status and the channel is <strong>stopped</strong> with a cryptic error:</p>
<p><span class="zoomImage__wrap"><img alt="WhatsApp stopped with the openKeyedStore trust error" src="https://development-wec.wiline.com/docs/assets/images/wa-trust-error-7a8033b008450d82190f783b79998f71.png" width="861" height="293" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> <code>stopped</code> — an untrusted plugin can’t use <code>openKeyedStore</code>, so the channel won’t start.</p>
<p>That's a <strong>security feature</strong>: OpenClaw sandboxes third-party plugins, and an untrusted
one can't use <code>openKeyedStore</code> (the storage WhatsApp needs for its session). Trust it —
include any other non-bundled plugin already loading (here <code>codex</code>) so you don't lock it
out — and recreate:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli config </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --batch-json </span><span class="token string" style="color:#e3116c">'[{"path":"plugins.allow","value":["codex","whatsapp"]}]'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> --force-recreate openclaw-gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels status</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="plugins.allow now lists codex and whatsapp" src="https://development-wec.wiline.com/docs/assets/images/wa-trust-fix-503842c5815cf95dc4bde07dc3b4a6ee.png" width="673" height="105" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Trusted — <code>plugins.allow</code> now lists <code>whatsapp</code> (and <code>codex</code>).</p>
<p>The restart matters — the gateway reads <code>plugins.allow</code> <em>before</em> it initializes the
plugin. Now it's up:</p>
<p><span class="zoomImage__wrap"><img alt="WhatsApp running and connected, Telegram still up" src="https://development-wec.wiline.com/docs/assets/images/wa-connected-d4ec6a75486c355d24570487f62305c8.png" width="860" height="294" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> Live — WhatsApp <code>running, connected, healthy</code>, and Telegram still up.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--link-your-phone-by-qr">Step 3 — Link your phone by QR<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#step-3--link-your-phone-by-qr" class="hash-link" aria-label="Direct link to Step 3 — Link your phone by QR" title="Direct link to Step 3 — Link your phone by QR" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels login</span><br></div></code></pre></div></div>
<p>Scan the terminal QR from <strong>WhatsApp → Settings → Linked Devices → Link a Device</strong>.</p>
<p><span class="zoomImage__wrap"><img alt="The pairing QR, linked after the code 515 restart" src="https://development-wec.wiline.com/docs/assets/images/wa-qr-544d7332e052ce0469c77d7e60fec42a.png" width="786" height="834" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> Scan to link; <code>✅ Linked after restart</code> handles the <code>code 515</code> reconnect.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>The <code>code 515</code> restart is normal</div><div class="admonitionContent_BuS1"><p>WhatsApp forces a reconnect right after pairing; OpenClaw handles it automatically. This
is exactly where hand-rolled setups get stuck in a loop — here it just works.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--test-it">Step 4 — Test it<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#step-4--test-it" class="hash-link" aria-label="Direct link to Step 4 — Test it" title="Direct link to Step 4 — Test it" translate="no">​</a></h2>
<p>From <strong>another phone</strong>, message the number:</p>
<p><span class="zoomImage__wrap"><img alt="The OpenClaw agent answering on WhatsApp" src="https://development-wec.wiline.com/docs/assets/images/wa-reply-08f4125cb353af3cff9a2418928be7ec.png" width="1320" height="2868" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> A question in, the agent’s answer back — sent from the number, as the account.</p>
<p>It replies — but notice it answers <strong>from your number, as you.</strong> That's companion mode:
the agent <em>is</em> the account. Great for a personal assistant, and the reason for the warning
below.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>Your OpenClaw agent answers on WhatsApp — natively, no bridge, no bot account.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="before-a-real-number">Before a real number<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#before-a-real-number" class="hash-link" aria-label="Direct link to Before a real number" title="Direct link to Before a real number" translate="no">​</a></h2>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>On a personal number the agent replies to everyone, as you</div><div class="admonitionContent_BuS1"><p>It processes <strong>every DM and group</strong> the account receives (<code>Listening for ... DM + all groups</code>). For anything real: link a <strong>dedicated number</strong> (a cheap prepaid SIM — virtual
numbers are often rejected), and set an <strong>allowlist</strong> before exposing it.</p></div></div>
<p>Park the channel when you're done testing (Telegram keeps running; your link stays saved):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli config </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  --batch-json </span><span class="token string" style="color:#e3116c">'[{"path":"channels.whatsapp.accounts.default.enabled","value":false}]'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> --force-recreate openclaw-gateway</span><br></div></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>openKeyedStore is only available for trusted plugins</code></strong> — channel installs but stays
<code>stopped</code>. Add the id to <code>plugins.allow</code> and <strong>recreate</strong> the gateway (Step 2). A live
config change isn't enough once it has already failed to start.</li>
<li class=""><strong>QR loops / <code>code 515</code></strong> — normal post-pairing restart. If it never settles, remove
stale entries in WhatsApp → Linked Devices and scan a fresh QR.</li>
<li class=""><strong>It replied to a real contact</strong> — companion mode answers everything; see the warning
above and use a dedicated number.</li>
</ul>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Chat from WhatsApp</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Part 5 done — your agent reaches Telegram <em>and</em> WhatsApp from a private, hardened box.</p>
<ul>
<li class="">Give it <strong>memory that survives restarts</strong> — the <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Hermes
Agent</a></strong> series.</li>
<li class="">Ground it in <em>your</em> docs — the <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">AI evals &amp;
observability</a></strong> series builds a RAG
service you can point a channel at.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels remove </span><span class="token parameter variable" style="color:#36acaa">--channel</span><span class="token plain"> whatsapp</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart openclaw-gateway</span><br></div></code></pre></div></div>
<p>And unlink the device in <strong>WhatsApp → Linked Devices</strong> if you're done.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>openclaw</category>
            <category>whatsapp</category>
            <category>chatbot</category>
        </item>
        <item>
            <title><![CDATA[Regression-test your RAG service with DeepEval — and settle a model debate with data]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/</guid>
            <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Wrap the RAG assistant from part 5 in a containerized DeepEval suite judged by gemma4 on the WEC Inference API — no OpenAI key anywhere. Then use it to answer a real question: should we swap our generation model? Same quality, 5.5× the tokens: the eval says no. Every command, number, and error is real.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__deepeval" src="data:image/png;base64,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" alt="DeepEval"><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">6</span><span class="skillTracker__skill" data-state="current">Catch regressions in CI</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Our <a class="" href="https://development-wec.wiline.com/docs/news/gpt-5-6-token-economics/">GPT-5.6 token-economics analysis</a> ended with a challenge:
benchmark promises are a hypothesis, not a reason — run the eval on <em>your</em> workload before you
believe a "fewer tokens per task" claim. This tutorial is us taking our own advice.</p>
<p>We take the RAG docs assistant from <a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">part 5</a>,
wrap it in a <strong>DeepEval regression suite</strong> — containerized, judged by <code>gemma4</code> on the WEC
Inference API, zero OpenAI dependency — and then use that suite to answer a genuinely open
question: <strong>should we swap the service's generation model?</strong> Qwen2.5-3B-Instruct (current) vs
gemma4 (candidate), pass rate and tokens per task, measured head to head.</p>
<p>Spoiler: the eval saved us from a pointless migration. And along the way we hit four real
production failures — a disk-full death spiral, a startup race condition, a Docker Compose
variable trap that silently evaluated the <em>wrong model</em>, and the fix that makes that class of
bug impossible. Every command, number, and error below is from a real run.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-were-building">What we're building<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#what-were-building" class="hash-link" aria-label="Direct link to What we're building" title="Direct link to What we're building" translate="no">​</a></h2>
<!-- -->
<p>Three design decisions up front:</p>
<ol>
<li class=""><strong>The eval runner is a container</strong>, not a venv. The suite you run locally is byte-for-byte
the artifact CI runs — <code>docker compose run eval</code> in both places. (Our first attempt was a
venv. It died to a full disk before installing anything; see Troubleshooting.)</li>
<li class=""><strong>The judge is gemma4 on WEC.</strong> DeepEval assumes OpenAI by default; we give it a 30-line
custom model class instead. Questions, answers, and verdicts never leave our infrastructure.</li>
<li class=""><strong>We reuse part 5's golden dataset unchanged.</strong> The promptfoo <code>tests.csv</code> embeds each
reference fact inside an <code>llm-rubric</code> string — one regex pulls it back out. Your eval data
outlives your eval framework.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">The <strong>running RAG service from <a class="" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/">part 5</a></strong>
(<code>rag-api</code> answering on <code>:8000</code>, <code>eval/tests.csv</code> present).</li>
<li class="">A <strong>WEC Inference API key</strong> (<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>).</li>
<li class=""><strong>~2 GB free disk.</strong> Seriously — check <code>df -h /</code> <em>now</em>. Ours read 100% and the story of why
is in Troubleshooting.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-0--smoke-test-the-system-under-test">Step 0 — smoke-test the system under test<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-0--smoke-test-the-system-under-test" class="hash-link" aria-label="Direct link to Step 0 — smoke-test the system under test" title="Direct link to Step 0 — smoke-test the system under test" translate="no">​</a></h2>
<p>Never eval a service you haven't poked by hand first:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST localhost:8000/ask </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"question": "How do I create a compute instance on WEC?"}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> json.tool</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The RAG service answering a docs question with sources" src="https://development-wec.wiline.com/docs/assets/images/deepeval-smoke-test-98290b8dda1f2a3f5e57a82fa43c2dfa.png" width="900" height="275" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> The system under test, alive: <code>/ask</code> answers from the WEC docs corpus and cites
its sources. Never eval a service you haven't poked by hand first.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--the-containerized-eval-runner">Step 1 — the containerized eval runner<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-1--the-containerized-eval-runner" class="hash-link" aria-label="Direct link to Step 1 — the containerized eval runner" title="Direct link to Step 1 — the containerized eval runner" translate="no">​</a></h2>
<p><code>eval/Dockerfile</code>:</p>
<div class="language-docker codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">eval/Dockerfile</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-docker codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">FROM python:3.12-slim</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WORKDIR /eval</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RUN pip install --no-cache-dir deepeval==3.*</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"># Suite code is mounted at runtime so edits don't need a rebuild</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">CMD ["deepeval", "test", "run", "test_rag_regression.py"]</span><br></div></code></pre></div></div>
<p>Add the service to <code>docker-compose.yml</code> <strong>inside the <code>services:</code> block</strong> (part 5's file ends
with a top-level <code>volumes:</code> section — appending blindly puts your service in the wrong block;
<code>docker compose config --quiet</code> catches it):</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.yml (new service, inside services:)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">eval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">build</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> ./eval</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">profiles</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"eval"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">env_file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> .env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">RAG_API_URL</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> http</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//rag</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">api</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">8000</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">volumes</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> ./eval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">/eval</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">depends_on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> rag</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">api</span><br></div></code></pre></div></div>
<p><code>profiles: ["eval"]</code> keeps it out of a normal <code>docker compose up</code> — it only runs when you ask.
Build it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token parameter variable" style="color:#36acaa">--profile</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> build </span><span class="token builtin class-name">eval</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose building the eval runner image" src="https://development-wec.wiline.com/docs/assets/images/deepeval-eval-build-58810296ef048e28edf1a9f90735d2eb.png" width="1619" height="295" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The eval runner builds in ~42s: <code>python:3.12-slim</code> plus DeepEval 3.x. This exact
image is what CI runs later — no venv, no drift.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--a-judge-on-your-own-infrastructure">Step 2 — a judge on your own infrastructure<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-2--a-judge-on-your-own-infrastructure" class="hash-link" aria-label="Direct link to Step 2 — a judge on your own infrastructure" title="Direct link to Step 2 — a judge on your own infrastructure" translate="no">​</a></h2>
<p>DeepEval's LLM-as-judge metrics want an OpenAI key. We don't have one and don't want one.
<code>eval/wec_judge.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">eval/wec_judge.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""Custom DeepEval judge backed by WiLine Inference (OpenAI-compatible)."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> OpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> deepeval</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">models</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">base_model </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> DeepEvalBaseLLM</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">WEC_BASE_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"WEC_BASE_URL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">class</span><span class="token plain"> </span><span class="token class-name">WECJudge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">DeepEvalBaseLLM</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">__init__</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> model</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">None</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">model_name </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> model </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"JUDGE_MODEL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"gemma4"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">client </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> OpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">WEC_BASE_URL</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">load_model</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">generate</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> prompt</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        response </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">chat</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">completions</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">create</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            model</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">model_name</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            messages</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> prompt</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> response</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">choices</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">a_generate</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> prompt</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> self</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">generate</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">prompt</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">get_model_name</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token string-interpolation string" style="color:#e3116c">f"WEC/</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">self</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">.</span><span class="token string-interpolation interpolation">model_name</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<p>Prove it answers from inside the compose network before building anything on it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token parameter variable" style="color:#36acaa">--profile</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  python </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"from wec_judge import WECJudge; j = WECJudge(); print('judge says:', j.generate('Reply with exactly: WEC judge online'))"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">judge says: WEC judge online</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Judge connectivity check from inside the compose network" src="https://development-wec.wiline.com/docs/assets/images/deepeval-judge-online-c5844beb37c759e19f698f4ea927ec8c.png" width="897" height="170" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> "WEC judge online" — gemma4 answering through the container, on the same compose
network as the service it will judge. No OpenAI key anywhere in the stack.</p>
<p>An honest caveat we carry through the rest of this tutorial: in the model comparison later,
<strong>gemma4 is both judge and contestant</strong>. The mitigation is structural — the judge only compares
answer-vs-reference-fact, never "which model wrote this" — and we watch its verdicts on the
known-hard cases for favoritism. (It showed none: every case it failed gemma4 on is a case it also fails Qwen on.)</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can point DeepEval's LLM-judged metrics at any OpenAI-compatible endpoint. Your eval
verdicts — questions, answers, reasoning — never leave your infrastructure.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--expose-what-you-want-to-measure">Step 3 — expose what you want to measure<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-3--expose-what-you-want-to-measure" class="hash-link" aria-label="Direct link to Step 3 — expose what you want to measure" title="Direct link to Step 3 — expose what you want to measure" translate="no">​</a></h2>
<p>Part 5's <code>/ask</code> returned <code>answer</code> and <code>sources</code> — and threw away the token usage that came back
with every completion. For token economics we need it, and for the A/B we need the model to be
switchable. Three env vars and two response fields in <code>app/main.py</code> (volume-mounted; uvicorn
hot-reloads, no rebuild):</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">app/main.py (excerpt)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># Generation model is configurable so the eval suite can A/B models.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Defaults preserve the tutorial-5 setup.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GEN_MODEL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"GEN_MODEL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GEN_BASE_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"GEN_BASE_URL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GEN_API_KEY </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"GEN_API_KEY"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p><code>generate()</code> now also returns <code>resp.usage</code>, and <code>/ask</code> reports both:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (abridged)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"answer"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"To view your billing statements ..."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"sources"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"..."</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"model"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"usage"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"prompt_tokens"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1238</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"completion_tokens"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">122</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"total_tokens"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1360</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="/ask response now reporting model and token usage" src="https://development-wec.wiline.com/docs/assets/images/deepeval-ask-usage-b1a5b036ae1af43fa37d1c91c9d2f092.png" width="1060" height="361" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The new <code>model</code> and <code>usage</code> fields: 1,238 prompt + 122 completion tokens for one
billing question. This is the number the token-economics debate is actually about.</p>
<p>That <code>usage</code> block is the number the GPT-5.6 marketing is about — measured on your workload,
per request. Pass the variable through compose (<code>GEN_MODEL: ${GEN_MODEL:-Qwen2.5-3B-Instruct}</code>
under <code>rag-api</code>'s <code>environment:</code>) and remember this line — it comes back to bite us in
Troubleshooting.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--the-regression-suite">Step 4 — the regression suite<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-4--the-regression-suite" class="hash-link" aria-label="Direct link to Step 4 — the regression suite" title="Direct link to Step 4 — the regression suite" translate="no">​</a></h2>
<p><code>eval/test_rag_regression.py</code>. It reads part 5's <code>tests.csv</code>, extracts the reference fact from
each promptfoo rubric, asks the live service, logs token usage, and asserts a GEval correctness
metric judged by <code>WECJudge</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">eval/test_rag_regression.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""DeepEval regression suite for the WEC docs RAG service.</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="display:inline-block;color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">Reuses the promptfoo golden set (tests.csv) from the previous tutorial:</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">the reference fact is parsed out of each llm-rubric string.</span><br></div><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> csv</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> re</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> pytest</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> deepeval </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> assert_test</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> deepeval</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">metrics </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> GEval</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> deepeval</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">test_case </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> LLMTestCase</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> LLMTestCaseParams</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> wec_judge </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> WECJudge</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RAG_API_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"RAG_API_URL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://rag-api:8000"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">USAGE_LOG </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"USAGE_LOG"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"usage_log.jsonl"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">MAX_CASES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"MAX_CASES"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"0"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic"># 0 = all</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">FACT_RE </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> re</span><span class="token punctuation" style="color:#393A34">.</span><span class="token builtin">compile</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">r'Reference fact from the official docs: "(.*?)"'</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">load_cases</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> </span><span class="token builtin">open</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"tests.csv"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> newline</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> f</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> row </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> csv</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">DictReader</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">f</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            m </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> FACT_RE</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">search</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"__expected"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">yield</span><span class="token plain"> row</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">m</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">group</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> m </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">""</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">CASES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">list</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">load_cases</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> MAX_CASES</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    CASES </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> CASES</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">MAX_CASES</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">judge </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> WECJudge</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">correctness </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> GEval</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    name</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"Correctness"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    criteria</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"The actual output must correctly address the input question and must not "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"contradict the reference fact given as expected output. Different wording "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"is fine. Fail only if the answer is wrong, contradicts the reference fact, "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"or fails to answer the question."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    evaluation_params</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        LLMTestCaseParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">INPUT</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        LLMTestCaseParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ACTUAL_OUTPUT</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        LLMTestCaseParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">EXPECTED_OUTPUT</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    model</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">judge</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    threshold</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0.5</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@pytest</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">mark</span><span class="token decorator annotation punctuation" style="color:#393A34">.</span><span class="token decorator annotation punctuation" style="color:#393A34">parametrize</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"question,reference"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> CASES</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> ids</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">q</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">50</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> q</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> _ </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> CASES</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">test_rag_regression</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">question</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> reference</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">RAG_API_URL</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/ask"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> question</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">120</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    data </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> r</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    expect </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"EXPECT_MODEL"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">assert</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">not</span><span class="token plain"> expect </span><span class="token keyword" style="color:#00009f">or</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> expect</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string-interpolation string" style="color:#e3116c">f"suite expected </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">expect</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> but service is running </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">data</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">[</span><span class="token string-interpolation interpolation string" style="color:#e3116c">'model'</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">]</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">with</span><span class="token plain"> </span><span class="token builtin">open</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">USAGE_LOG</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"a"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">as</span><span class="token plain"> f</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        f</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">write</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dumps</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"question"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> question</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token operator" style="color:#393A34">**</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"usage"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"\n"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    assert_test</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        LLMTestCase</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token builtin">input</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">question</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            actual_output</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"answer"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            expected_output</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">reference</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">correctness</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>(That <code>EXPECT_MODEL</code> assertion wasn't in the first version. It exists because of a bug you'll
meet in a moment.)</p>
<p>First run, capped at 5 cases to fail fast:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token parameter variable" style="color:#36acaa">--profile</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">MAX_CASES</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">5</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">DEEPEVAL_TELEMETRY_OPT_OUT</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">YES </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">eval</span><br></div></code></pre></div></div>
<p><strong>4 of 5 passed in 2:37</strong> — and both the passes and the failure are worth reading. The
failure ("What is the starting point for billing?", score 0.3) isn't a service bug: the
answer was factually fine, but the golden label is a section <em>title</em> ("How Billing Works")
and the judge wanted it named. Remember this case — it flickers between pass and fail all
tutorial long, and that flicker becomes a finding. Meanwhile on "total number of events"
the judge scored a 0.6 borderline pass with exactly the right reasoning (the service
described the feature instead of giving steps). Nuanced verdicts from a self-hosted judge —
the thing people assume you need GPT-4-class models for.</p>
<p><span class="zoomImage__wrap"><img alt="First DeepEval run: 4 of 5 passed, with judge reasoning" src="https://development-wec.wiline.com/docs/assets/images/deepeval-first-run-cabfff276f20b525d0c7c20bb52c1a6a.png" width="1058" height="984" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> First run: 4 of 5 passed in 2:37, every verdict with written reasoning from
the gemma4 judge — including the one failure, a strict verdict on a section-title label
rather than a service bug.</p>
<p>Note DeepEval's summary line: <code>token cost: None</code>. It can't price a self-hosted judge — which is
exactly why the suite writes its own <code>usage_log.jsonl</code>.</p>
<p><span class="zoomImage__wrap"><img alt="Per-question token log written by the suite" src="https://development-wec.wiline.com/docs/assets/images/deepeval-usage-log-65042f9bd05652b9ff3814cd52b37270.png" width="897" height="575" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The suite’s own accounting: one JSON line per question — model, prompt, completion,
total — the raw data every comparison in this tutorial is built from.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You have a regression suite grading a live RAG service — reusing last tutorial's golden set
unchanged, judged by a self-hosted model, with per-question token accounting the framework
itself doesn't provide.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--the-experiment-qwen25-3b-vs-gemma4">Step 5 — the experiment: Qwen2.5-3B vs gemma4<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-5--the-experiment-qwen25-3b-vs-gemma4" class="hash-link" aria-label="Direct link to Step 5 — the experiment: Qwen2.5-3B vs gemma4" title="Direct link to Step 5 — the experiment: Qwen2.5-3B vs gemma4" translate="no">​</a></h2>
<p>The question the suite exists to answer: our service generates with Qwen2.5-3B-Instruct —
should it? gemma4 is newer, well-regarded, already in the WEC catalog. Feels like an upgrade.
<em>Feels.</em> Let's measure.</p>
<p>Baseline, all 16 cases:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token parameter variable" style="color:#36acaa">--profile</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">DEEPEVAL_TELEMETRY_OPT_OUT</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">YES </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">USAGE_LOG</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">usage_qwen.jsonl </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">eval</span><br></div></code></pre></div></div>
<p><strong>14/16 (87.5%) in 8:50.</strong> The two failures are label artifacts, not RAG bugs: the golden facts
from part 5 are section <em>titles</em> ("How Billing Works"), and the judge dinged answers that were
factually fine but didn't echo the title; and <code>mp3</code> vs the label <code>audio/mpeg</code> — the same fact at
two abstraction levels, ruled a contradiction. Read your failures before trusting your pass
rate. We leave both in: they're honest label debt, and they become our judge-favoritism canary
in the A/B.</p>
<p><span class="zoomImage__wrap"><img alt="Qwen baseline: 14 of 16 passed" src="https://development-wec.wiline.com/docs/assets/images/deepeval-qwen-baseline-5b61265199e0d615241bbbfdb30d7a81.png" width="1890" height="977" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The baseline: 87.5% in 8:50. Both failures are label artifacts, not RAG bugs —
read your failures before trusting your pass rate.</p>
<p>Challenger — same suite, one env var (prefix it on <strong>every</strong> compose command; Troubleshooting
explains why in blood):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">GEN_MODEL</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">gemma4 </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> rag-api</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">until</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sf</span><span class="token plain"> localhost:8000/health </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> /dev/null</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"waiting..."</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sleep</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-X</span><span class="token plain"> POST localhost:8000/ask </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"question": "ping"}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import json,sys; print('&gt;&gt;&gt; model is:', json.load(sys.stdin)['model'])"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">GEN_MODEL</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">gemma4 </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token parameter variable" style="color:#36acaa">--profile</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">DEEPEVAL_TELEMETRY_OPT_OUT</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">YES </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">USAGE_LOG</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">usage_gemma4.jsonl </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">EXPECT_MODEL</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">gemma4 </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">eval</span><br></div></code></pre></div></div>
<p>And verify the log, because run labels lie:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sort</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> eval/usage_gemma4.jsonl </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"import json,sys; [print(json.loads(l)['model']) for l in sys.stdin]"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">uniq</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">#     16 gemma4</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Switching the service to gemma4 and verifying from the response" src="https://development-wec.wiline.com/docs/assets/images/deepeval-gemma4-run-6a7c64183ec8b68e6d5e5ff76dd0924a.png" width="744" height="196" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> Switching the challenger in: <code>GEN_MODEL=gemma4</code>, wait for <code>/health</code>, then confirm
<code>&gt;&gt;&gt; model is: gemma4</code> from the response itself — before spending a single eval minute.</p>
<p><span class="zoomImage__wrap"><img alt="gemma4 run summary: 13 of 16 passed" src="https://development-wec.wiline.com/docs/assets/images/deepeval-gemma4-verified-8d7291e9dfcda2f337f83ca7e4988071.png" width="1890" height="981" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> The gemma4 run: 81.25% in ~13 minutes — and its three failures are the same
label-artifact cases Qwen flickers on. No favoritism from the gemma4 judge toward the gemma4
contestant.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-verdict-table">The verdict table<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#the-verdict-table" class="hash-link" aria-label="Direct link to The verdict table" title="Direct link to The verdict table" translate="no">​</a></h3>
<p>One script over the usage logs:</p>
<table><thead><tr><th>run</th><th>pass rate</th><th>avg prompt tokens</th><th>avg completion tokens</th><th>total tokens</th></tr></thead><tbody><tr><td>Qwen2.5-3B (run 1)</td><td>87.5%</td><td>1,126</td><td><strong>75</strong></td><td>19,225</td></tr><tr><td>Qwen2.5-3B (run 2)</td><td>81.25%</td><td>1,126</td><td><strong>75</strong></td><td>19,225</td></tr><tr><td>gemma4</td><td>81.25%</td><td>1,199</td><td><strong>415</strong></td><td>25,834</td></tr></tbody></table>
<p><span class="zoomImage__wrap"><img alt="Token comparison table: Qwen vs gemma4" src="https://development-wec.wiline.com/docs/assets/images/deepeval-verdict-table-fc9c10d332e08f09d63c768e48c1b23c.png" width="752" height="408" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 10.</strong> The verdict in five lines: same pass rate, 5.5× the completion tokens. The eval
just prevented a pointless migration.</p>
<p>Two findings, one expected and one not:</p>
<p><strong>The verdict: don't switch.</strong> Same effective quality, same failure cases — but gemma4 uses
<strong>5.5× the completion tokens</strong> (415 vs 75 per answer) and runs ~45% slower per question. On a
RAG workload where the ~1,100-token retrieved context dominates the prompt, the completion
column is the whole cost difference. "Newer model" lost to "measure it" in one afternoon. This
is precisely the eval the GPT-5.6 news post said to run — and it's the same eval you'd run
against GPT-5.6 itself: change <code>GEN_MODEL</code>, <code>GEN_BASE_URL</code>, and <code>GEN_API_KEY</code>, nothing else.</p>
<p><strong>The judge has a noise floor.</strong> Look at the two Qwen rows: <strong>identical token counts</strong> —
19,225 in both runs, because temperature-0 generation is deterministic — yet 87.5% vs 81.25%.
The answers didn't change; only the judge's verdicts did (one borderline 0.6 became a 0.3).
That cleanly isolates judge nondeterminism from model behavior, and it sets your CI threshold:
a gate at "≥ 87.5%" would flake. Ours tolerates one flipped verdict and fails on two or more
real regressions.</p>
<p><span class="zoomImage__wrap"><img alt="Qwen run 2: identical answers, different verdicts" src="https://development-wec.wiline.com/docs/assets/images/deepeval-qwen-run2-48156c484cd782d1e346e0938b1080b9.png" width="1055" height="981" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 11.</strong> Qwen run 2: the same 16 deterministic answers, 81.25% this time — the judge, not
the model, is where the variance lives.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can settle a model-swap debate with measured data — pass rate <em>and</em> tokens per task — and
you know your judge's noise floor, so you can set a CI threshold that won't flake.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--the-ci-gate">Step 6 — the CI gate<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#step-6--the-ci-gate" class="hash-link" aria-label="Direct link to Step 6 — the CI gate" title="Direct link to Step 6 — the CI gate" translate="no">​</a></h2>
<p>The payoff of containerizing everything: the CI job is the command you've been running all
along. <code>.github/workflows/eval.yml</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">.github/workflows/eval.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> rag</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">regression</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">eval</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">pull_request</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">paths</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"app/**"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"eval/**"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">workflow_dispatch</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">jobs</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">eval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">runs-on</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">self</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">hosted</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> wec</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># runner with access to inference.wiline.com</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">steps</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">uses</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> actions/checkout@v4</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Run regression suite</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">env</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">          </span><span class="token key atrule" style="color:#00a4db">WEC_API_KEY</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> secrets.WEC_API_KEY </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">run</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">|</span><span class="token scalar string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">          docker compose up -d rag-api</span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">          docker compose --profile eval run --rm \</span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">            -e DEEPEVAL_TELEMETRY_OPT_OUT=YES \</span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">            -e EXPECT_MODEL=Qwen2.5-3B-Instruct \</span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">            eval</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">name</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> Teardown</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">if</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> always()</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">run</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> docker compose down</span><br></div></code></pre></div></div>
<p>Any PR that touches the app or the eval must survive 16 judged questions against the live
service — with the model identity asserted — before it merges.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>Your eval is a CI gate. The container you ran locally is byte-for-byte what CI runs — no
"works on my machine" gap between development and the pipeline.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting--the-four-real-failures">Troubleshooting — the four real failures<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#troubleshooting--the-four-real-failures" class="hash-link" aria-label="Direct link to Troubleshooting — the four real failures" title="Direct link to Troubleshooting — the four real failures" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="errno-28-no-space-left-on-device--and-it-came-back"><code>[Errno 28] No space left on device</code> — and it came back<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#errno-28-no-space-left-on-device--and-it-came-back" class="hash-link" aria-label="Direct link to errno-28-no-space-left-on-device--and-it-came-back" title="Direct link to errno-28-no-space-left-on-device--and-it-came-back" translate="no">​</a></h3>
<p>Our first <code>pip install deepeval</code> died: disk 100% full, 58G of 58G. We freed 2 GB of journal
logs and build cache… and it was full again <em>minutes later</em>. The culprit:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (the culprit)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">7.9G  /var/lib/docker/containers/10281d.../10281d...-json.log</span><br></div></code></pre></div></div>
<p><code>langfuse-clickhouse-1</code> was in an error loop, spraying stack traces into a Docker json-log with
<strong>no rotation configured</strong> — a death spiral, because a full disk causes more errors causes more
log. It had been "unhealthy" for 8 days. The fix, in order (order matters — truncating while
the container runs just refills):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> stop langfuse-clickhouse-1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"truncate -s 0 /var/lib/docker/containers/10281d*/*-json.log"</span><br></div></code></pre></div></div>
<p>Then make it permanent — log rotation in the compose override, applied to every service:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.override.yml (Langfuse stack)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">x-logging</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token important">&amp;default-logging</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">logging</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">driver</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> json</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">file</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">options</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">max-size</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"50m"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">max-file</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"3"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">clickhouse</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">&lt;&lt;</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token important">*default-logging</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic"># ... every other service</span><br></div></code></pre></div></div>
<p><code>docker compose up -d</code> to recreate. ClickHouse went healthy for the first time in 8 days.
Docker's json-file driver does <strong>not</strong> rotate by default; on a box running LLM infrastructure
that logs enthusiastically, unbounded container logs are a time bomb.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="13-tests-fail-with-connection-refused-then-3-pass">13 tests fail with <code>Connection refused</code>, then 3 pass<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#13-tests-fail-with-connection-refused-then-3-pass" class="hash-link" aria-label="Direct link to 13-tests-fail-with-connection-refused-then-3-pass" title="Direct link to 13-tests-fail-with-connection-refused-then-3-pass" translate="no">​</a></h3>
<p>Changing <code>GEN_MODEL</code> recreates <code>rag-api</code>, and the app takes ~3 minutes to boot (Chroma +
embedding model load). Our suite started hammering <code>/ask</code> immediately: 13 straight
connection-refused failures, then the API came alive mid-run and the last 3 passed. Flaky in
the worst way — the failure depends on wall-clock timing.</p>
<p>Fix it in the suite, not in human discipline — <code>eval/conftest.py</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">eval/conftest.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token triple-quoted-string string" style="color:#e3116c">"""Refuse to start the suite until the RAG API is healthy."""</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> time</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> requests</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">RAG_API_URL </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"RAG_API_URL"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"http://rag-api:8000"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">READY_TIMEOUT </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">getenv</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"READY_TIMEOUT"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"300"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">pytest_configure</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">config</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    deadline </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">+</span><span class="token plain"> READY_TIMEOUT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">while</span><span class="token plain"> time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain"> deadline</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">try</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">RAG_API_URL</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">/health"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">raise_for_status</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">except</span><span class="token plain"> requests</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">RequestException</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sleep</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">raise</span><span class="token plain"> RuntimeError</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string-interpolation string" style="color:#e3116c">f"rag-api not healthy after </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">READY_TIMEOUT</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">s — aborting eval"</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-compose-variable-trap-we-evaluated-the-wrong-model-twice">The compose variable trap: we evaluated the wrong model. Twice.<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#the-compose-variable-trap-we-evaluated-the-wrong-model-twice" class="hash-link" aria-label="Direct link to The compose variable trap: we evaluated the wrong model. Twice." title="Direct link to The compose variable trap: we evaluated the wrong model. Twice." translate="no">​</a></h3>
<p>The nastiest one, because nothing errored. <code>${GEN_MODEL:-Qwen…}</code> in compose is interpolated
from <em>your shell</em> at <em>every</em> invocation. We started the service with
<code>GEN_MODEL=gemma4 docker compose up -d</code> — correct. Then ran the eval <strong>without</strong> the prefix.
Compose resolved the variable back to the Qwen default, saw the running container as config
drift, and — via <code>depends_on</code> — <strong>silently recreated <code>rag-api</code> with Qwen</strong>. The run completed,
the summary said "gemma4" in every verdict (the <em>judge</em> was gemma4), and every answer had been
written by Qwen.</p>
<p>We only caught it because the suite logs the model <em>from the response</em>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sort</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">. usage_gemma4.jsonl </span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">.</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">uniq</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">#     16 Qwen2.5-3B-Instruct     ← the "gemma4" run</span><br></div></code></pre></div></div>
<p>Never trust the label on a run; verify from data the system under test reports about itself.
The permanent fix is the <code>EXPECT_MODEL</code> assertion in the suite (Step 4). Proof it works — the
deliberate-failure test:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">FAILED ... AssertionError: suite expected gemma4 but service is running Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">1 failed in 2.44s</span><br></div></code></pre></div></div>
<p>Under three seconds, before spending a single judge call — versus the 10+ minutes each mislabeled run
cost us.</p>
<p><span class="zoomImage__wrap"><img alt="EXPECT_MODEL guard failing loudly on the wrong model" src="https://development-wec.wiline.com/docs/assets/images/deepeval-expect-model-ccc34be3d936ecfd93823c565073b405.png" width="1058" height="729" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 12.</strong> The guard in action: wrong model → loud failure in under three seconds, before a
single judge call is spent. Compare with the 10+ minutes each silently mislabeled run cost us.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="sleep-5-is-not-a-readiness-check"><code>sleep 5</code> is not a readiness check<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#sleep-5-is-not-a-readiness-check" class="hash-link" aria-label="Direct link to sleep-5-is-not-a-readiness-check" title="Direct link to sleep-5-is-not-a-readiness-check" translate="no">​</a></h3>
<p>Bonus small one: after recreating the container we piped <code>/ask</code> output into a JSON parser 5
seconds later and got <code>json.decoder.JSONDecodeError: Expecting value: line 1 column 1</code> — the
server wasn't up, curl returned empty, the parser choked on nothing. Poll <code>/health</code> in a loop;
never sleep a guessed number.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-built">What you built<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#what-you-built" class="hash-link" aria-label="Direct link to What you built" title="Direct link to What you built" translate="no">​</a></h2>
<ul>
<li class="">A <strong>containerized DeepEval regression suite</strong> — same image locally and in CI</li>
<li class="">A <strong>custom judge on WEC Inference</strong> (<code>gemma4</code> + GEval): verdicts with reasoning, no OpenAI key</li>
<li class=""><strong>Per-question token accounting</strong> the framework itself couldn't give you</li>
<li class="">A <strong>measured model decision</strong>: gemma4 ≈ Qwen on quality, 5.5× the completion tokens — keep Qwen</li>
<li class="">A <strong>judge noise-floor measurement</strong> (deterministic answers, a 6.25-point pass-rate swing) that sets your CI threshold honestly</li>
<li class="">An <strong><code>EXPECT_MODEL</code> guard</strong> that turns silently-evaluated-the-wrong-model into an under-three-second loud failure</li>
<li class="">A <strong>GitHub Actions gate</strong> that runs the identical container on every PR</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Catch regressions in CI</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The suite judges <em>answers</em>. Part 4's Langfuse traces know <em>why</em> — which chunks were retrieved,
what the generation span cost, where latency hides. Wiring eval failures back to their traces
(DeepEval test ID ↔ Langfuse trace ID) turns "test failed" into "here's the retrieval that
caused it." That's the component-level debugging story, and it's where this series goes next.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://deepeval.com/docs/models-custom" target="_blank" rel="noopener noreferrer" class="">DeepEval docs — custom models</a></li>
<li class=""><a href="https://deepeval.com/docs/metrics-llm-evals" target="_blank" rel="noopener noreferrer" class="">DeepEval — GEval metric</a></li>
<li class=""><a href="https://docs.docker.com/engine/logging/drivers/json-file/" target="_blank" rel="noopener noreferrer" class="">Docker logging drivers — json-file rotation</a></li>
<li class=""><a href="https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/" target="_blank" rel="noopener noreferrer" class="">Compose variable interpolation</a></li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>evals</category>
            <category>deepeval</category>
            <category>rag</category>
            <category>docker</category>
            <category>ci</category>
            <category>inference</category>
        </item>
        <item>
            <title><![CDATA[The capstone: build, evaluate, and observe a RAG docs assistant on the WEC API]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/</guid>
            <pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Build a production-shaped RAG service in Docker: scrape a real docs site, embed locally, generate on the WEC Inference API — then catch a real hallucination, root-cause it to your own scraper, fix it, and pin it with a regression test. Every command, number, and error is real.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-avatar-31b32c657d72dac0a7edbd77c24ec717.jpg" alt="Promptfoo"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">5</span><span class="skillTracker__skill" data-state="current">RAG, end to end</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Everything in this series was building to this. You can prove a model works
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">part 1</a>), make its output machine-reliable
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/">part 2</a>), generate real test data
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/">part 3</a>), and observe production
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">part 4</a>). Now we spend all four skills at once on
the pattern behind almost every serious LLM product: <strong>RAG</strong> — retrieval-augmented generation.</p>
<p>We'll build a <strong>docs assistant</strong>: a containerized HTTP service that answers customer questions
from WEC's own documentation. Not a notebook — a service, Docker-first, the shape you'd actually
deploy. And because this series doesn't do happy-path demos: along the way our RAG <strong>hallucinates a
GPU price</strong>, we root-cause it to <strong>our own scraper</strong>, fix it, and pin the fix with a regression
test. Every command, number, and error below is from a real run.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-were-building">What we're building<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#what-were-building" class="hash-link" aria-label="Direct link to What we're building" title="Direct link to What we're building" translate="no">​</a></h2>
<!-- -->
<p>One architecture decision up front: we split the work. <strong>Embeddings run locally</strong> (a small ONNX
model — fast, free, no GPU needed) and <strong>generation runs on WEC</strong>. This is a completely standard
production split: retrieval is cheap and latency-sensitive, so keeping it next to your vector
store saves a network round-trip per query — while generation is where the big model earns its
keep.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Prefer managed embeddings? WEC has them now</div><div class="admonitionContent_BuS1"><p>The WEC catalog now ships <strong><code>bge-m3</code></strong> (1024-dim, multilingual, batch input) on
<code>/v1/embeddings</code>:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/embeddings </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"bge-m3","input":["first chunk","second chunk"]}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'.data | length'</span><br></div></code></pre></div></div><p>Swapping this tutorial over is a two-function change — replace the <code>fastembed</code> calls in
<code>ingest.py</code> and <code>main.py</code> with the same OpenAI client pointed at <code>/v1/embeddings</code>
(<code>wec.embeddings.create(model="bge-m3", input=chunks)</code>), and re-ingest (the vector dimension
changes from 384 to 1024, so drop and rebuild the collection). We keep the local path below
because it's free, offline, and every number in this guide was measured with it — but both are
production-legitimate; pick per your latency and ops preferences.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class=""><strong>Docker + Compose</strong> on a WEC Instance (the same setup as
<a class="" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/">part 4</a>'s Langfuse box works).</li>
<li class="">A <strong>WEC Inference API key</strong> (<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>).</li>
<li class="">Your part-4 <strong>Langfuse</strong> instance and keys (optional but recommended — used in Step 3's tracing).</li>
<li class=""><strong>~1 GB free disk</strong> for the image. Check first: <code>df -h /</code>. Our box was at 99% and survived, but
tight disk is the #1 silent killer of Docker builds.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--scaffold-the-service-docker-first">Step 1 — Scaffold the service, Docker-first<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#step-1--scaffold-the-service-docker-first" class="hash-link" aria-label="Direct link to Step 1 — Scaffold the service, Docker-first" title="Direct link to Step 1 — Scaffold the service, Docker-first" translate="no">​</a></h2>
<p>Real RAG features ship as services, so we start as one: a FastAPI app in a container, code
bind-mounted so <code>uvicorn --reload</code> picks up every edit — no rebuild per change.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/rag-service/app </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> ~/rag-service</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> requirements.txt </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">fastapi==0.115.6</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">uvicorn==0.34.0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">chromadb==0.5.23</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">fastembed==0.4.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">openai==1.59.7</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">langfuse</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">requests==2.32.3</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">beautifulsoup4==4.12.3</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Dockerfile </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">FROM python:3.12-slim</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">WORKDIR /srv</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">COPY requirements.txt .</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">RUN pip install --no-cache-dir -r requirements.txt</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">COPY app/ app/</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EXPOSE 8000</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> docker-compose.yml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">services:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  rag-api:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    build: .</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    ports:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - "8000:8000"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - ./app:/srv/app          # live code - edit on host, uvicorn reloads</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - chroma-data:/data       # persisted vector index</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - model-cache:/root/.cache # embedding model cache (survives rebuilds)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    environment:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      ANONYMIZED_TELEMETRY: "False"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      WEC_API_KEY: ${WEC_API_KEY}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      LANGFUSE_HOST: ${LANGFUSE_HOST}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  chroma-data:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  model-cache:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p>Secrets live in an <code>.env</code> file next to the compose (compose loads it automatically — keys never
enter the YAML). Keep it out of version control from the start:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">WEC_API_KEY=sk-your-wec-key</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">LANGFUSE_PUBLIC_KEY=pk-lf-…</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">LANGFUSE_SECRET_KEY=sk-lf-…</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">LANGFUSE_HOST=http://&lt;your-langfuse-host&gt;:3000</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">".env"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> .gitignore</span><br></div></code></pre></div></div>
<p>A minimal app so the container has something to serve:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> app/main.py </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from fastapi import FastAPI</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">app = FastAPI(title="WEC docs RAG")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@app.get("/health")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def health():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return {"status": "ok"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:8000/health</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{"status":"ok"}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The containerized service alive — compose ps + health check" src="https://development-wec.wiline.com/docs/assets/images/rag-deploy-health-850c74e1ed3eff7a20368db371456f79.png" width="948" height="117" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> One command each: the container up, <code>/health</code> answering, and the disk check
(ours reads 99% — this box lives dangerously). The build takes ~90s and a ~700 MB image.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--ingest-build-the-corpus-from-a-live-website">Step 2 — Ingest: build the corpus from a live website<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#step-2--ingest-build-the-corpus-from-a-live-website" class="hash-link" aria-label="Direct link to Step 2 — Ingest: build the corpus from a live website" title="Direct link to Step 2 — Ingest: build the corpus from a live website" translate="no">​</a></h2>
<p>In the real world, the knowledge base you'll want your assistant to answer from usually isn't a
tidy folder of markdown — it's a <strong>live website</strong>: product docs, a wiki, a help center. So the
corpus-acquisition skill worth learning is real scraping, not file loading.</p>
<p>For this tutorial we scrape <strong>WEC's own documentation site</strong>. It's built with Docusaurus, and
Docusaurus (like most site generators) publishes a <code>sitemap.xml</code> — which means you can discover
every page programmatically instead of guessing URLs. The same approach works on any site that
ships a sitemap:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://wec.wiline.com/docs/sitemap.xml </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"&lt;loc&gt;"</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># 109 URLs on the whole site</span><br></div></code></pre></div></div>
<p>We filter to the product docs (<code>/docs/cloud_portal/</code>), pull each page, and keep only the
<code>&lt;article&gt;</code> element — Docusaurus wraps the actual content in it, so nav/sidebar/footer never reach
the index:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> app/ingest.py </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"""Scrape the WEC docs site -&gt; chunk -&gt; embed locally -&gt; Chroma."""</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import re, requests, chromadb</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from bs4 import BeautifulSoup</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from fastembed import TextEmbedding</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">SITEMAP = "https://wec.wiline.com/docs/sitemap.xml"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">FILTER  = "/docs/cloud_portal/"          # product docs only</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">CHUNK, OVERLAP = 700, 100                # chars</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def discover():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    xml = requests.get(SITEMAP, timeout=30).text</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    urls = re.findall(r"&lt;loc&gt;([^&lt;]+)&lt;/loc&gt;", xml)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return [u for u in urls if FILTER in u]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def scrape(url):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    html = requests.get(url, timeout=30).text</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    art = BeautifulSoup(html, "html.parser").find("article")  # Docusaurus main content</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return art.get_text("\n", strip=True) if art else ""</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def chunk(text):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    out, i = [], 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    while i &lt; len(text):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        out.append(text[i:i + CHUNK])</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        i += CHUNK - OVERLAP</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return out</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">if __name__ == "__main__":</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    urls = discover()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f"sitemap -&gt; {len(urls)} product-doc pages")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    docs, metas = [], []</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    for u in urls:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        for c in chunk(scrape(u)):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            docs.append(c)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            metas.append({"url": u})</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f"scraped -&gt; {len(docs)} chunks")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    embedder = TextEmbedding("BAAI/bge-small-en-v1.5")   # local, ~66MB ONNX, no GPU needed</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    vectors = [v.tolist() for v in embedder.embed(docs)]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f"embedded -&gt; {len(vectors)} vectors (dim {len(vectors[0])})")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    db = chromadb.PersistentClient(path="/data")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    col = db.get_or_create_collection("wec-docs")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    col.add(ids=[str(i) for i in range(len(docs))],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            documents=docs, metadatas=metas, embeddings=vectors)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    print(f"indexed -&gt; collection 'wec-docs' now has {col.count()} chunks")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> rag-api python </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> app.ingest</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">sitemap -&gt; 37 product-doc pages</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">scraped -&gt; 281 chunks</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">model_optimized.onnx: 100%|████████████| 66.5M/66.5M [00:03&lt;00:00, 16.9MB/s]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">embedded -&gt; 281 vectors (dim 384)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">indexed -&gt; collection 'wec-docs' now has 281 chunks</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Ingest run — 37 pages, 281 chunks, embedded and indexed" src="https://development-wec.wiline.com/docs/assets/images/rag-ingest-2a1ffde1c98f1968bbf60532fa0a0a00.png" width="941" height="357" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The whole product-doc corpus indexed in under a minute. The embedding model caches in
the <code>model-cache</code> volume, so it downloads exactly once.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can turn any documentation website into a queryable vector index — sitemap discovery, content
extraction, chunking, local embeddings.</p></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About <code>get_text("\n", …)</code></div><div class="admonitionContent_BuS1"><p>Our first version used <code>get_text(" ")</code> — a single space as the separator. It looked harmless and
<strong>caused a genuine hallucination</strong> you'll see in Step 4. Keep the newline; we'll come back to this.
(Want to watch the bug happen on your own box? Change <code>"\n"</code> back to <code>" "</code> in <code>scrape()</code>, re-run
the ingest, and ask the Step-4 question — then put the newline back and re-ingest.)</p></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Chroma telemetry noise</div><div class="admonitionContent_BuS1"><p>You may see <code>Failed to send telemetry event ClientStartEvent: capture() takes 1 positional argument…</code> — a known chromadb/posthog version clash. It's harmless (your data is fine), and the
<code>ANONYMIZED_TELEMETRY: "False"</code> env in the compose silences it.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-ask-endpoint-retrieve-generate-trace">Step 3 — The <code>/ask</code> endpoint: retrieve, generate, trace<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#step-3--the-ask-endpoint-retrieve-generate-trace" class="hash-link" aria-label="Direct link to step-3--the-ask-endpoint-retrieve-generate-trace" title="Direct link to step-3--the-ask-endpoint-retrieve-generate-trace" translate="no">​</a></h2>
<p>Now the service itself. Three design choices worth stating:</p>
<ul>
<li class=""><strong>Citations come from retrieval metadata, not the model.</strong> The model can hallucinate URLs; the
vector store cannot. <code>sources</code> is deterministic.</li>
<li class=""><strong>The WEC call goes through <code>langfuse.openai</code></strong> (part 4's drop-in), and each stage gets an
<code>@observe</code> span — so every request produces a <strong><code>ask → retrieve → generate</code> tree</strong> in Langfuse.</li>
<li class=""><strong><code>timeout=60</code></strong> on the client. Part 4's lesson: no default timeout means a stalled backend hangs
your service silently.</li>
</ul>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> app/main.py </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import os, chromadb</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from fastapi import FastAPI</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from pydantic import BaseModel</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from fastembed import TextEmbedding</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from langfuse import observe, get_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from langfuse.openai import openai          # auto-traces the WEC call</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">app = FastAPI(title="WEC docs RAG")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">db = chromadb.PersistentClient(path="/data")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">col = db.get_or_create_collection("wec-docs")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">embedder = TextEmbedding("BAAI/bge-small-en-v1.5")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">wec = openai.OpenAI(</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    base_url="https://inference.wiline.com/v1",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    api_key=os.environ["WEC_API_KEY"],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    timeout=60,</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">class Ask(BaseModel):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    question: str</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@observe()                                   # span: retrieve</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def retrieve(question: str, k: int = 4):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    qv = list(embedder.embed([question]))[0].tolist()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    res = col.query(query_embeddings=[qv], n_results=k)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    chunks = res["documents"][0]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    sources = sorted({m["url"] for m in res["metadatas"][0]})</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return chunks, sources</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@observe()                                   # span: generate (WEC call nested inside)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def generate(question: str, chunks: list[str]) -&gt; str:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    context = "\n---\n".join(chunks)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    resp = wec.chat.completions.create(</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        model="Qwen2.5-3B-Instruct",     # small on purpose: cheap + fast per query,</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                                         # and its failures teach (see Step 5)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        name="rag-generate",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        messages=[{"role": "user", "content":</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            "Answer the question using ONLY the context below. "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            "If the context doesn't contain the answer, say so. "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            "When quoting a price or number, copy it VERBATIM with its unit and the item "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            "it belongs to; never combine numbers from different lines and never compute "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            "new numbers from examples.\n\n"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            f"Context:\n{context}\n\nQuestion: {question}"}],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    )</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return resp.choices[0].message.content</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@app.post("/ask")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@observe()                                   # root span: the whole request</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def ask(body: Ask):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    chunks, sources = retrieve(body.question)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    answer = generate(body.question, chunks)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    get_client().flush()</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return {"answer": answer, "sources": sources}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">@app.get("/health")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">def health():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    return {"status": "ok"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--build</span><br></div></code></pre></div></div>
<p>Ask it a real customer question:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:8000/ask </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"question": "How do I create an API key for the Inference API?"}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> json.tool</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (abridged)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"answer"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"To create an API key for the Inference API, follow these steps:\n1. Log in to the WiLine Edge Cloud.\n2. Expand the \"Inference\" section in the left sidebar.\n3. Click on the \"API Keys\" tab.\n4. Click the \"+ Create Key\" button located above the keys table. ..."</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token property" style="color:#36acaa">"sources"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"https://wec.wiline.com/docs/cloud_portal/platform/inference/api/wiline-edge-cloud-inference-api/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"https://wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"https://wec.wiline.com/docs/cloud_portal/platform/inference/examples/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"https://wec.wiline.com/docs/cloud_portal/platform/inference/models_hub/"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string" style="color:#e3116c">"https://wec.wiline.com/docs/cloud_portal/platform/inference/overview/"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="First real answer — correct steps, correct sources" src="https://development-wec.wiline.com/docs/assets/images/rag-first-ask-301b6dfc7a64a463e0eb1e6d93b53837.png" width="834" height="308" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> First try: the exact API-key flow from the docs, with the right page cited. Scrape →
embed → retrieve → generate, end to end.</p>
<p>Open Langfuse → Tracing and you'll find the request as a <strong>span tree</strong> — <code>ask → retrieve → generate → rag-generate</code> — with per-step latency and tokens. When an answer is bad, this is how you
tell <em>bad retrieval</em> from <em>bad generation</em> (part 4's skill, now on a real app).</p>
<p><span class="zoomImage__wrap"><img alt="The RAG request as a span tree in Langfuse" src="https://development-wec.wiline.com/docs/assets/images/rag-trace-tree-769e5224814989dedc4896f9e4e1873f.png" width="1904" height="996" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Every <code>/ask</code> becomes a tree: retrieval and generation each have an address.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You have a traced, containerized RAG service answering from live-scraped documentation.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--the-hallucination-we-caught-for-real">Step 4 — The hallucination we caught (for real)<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#step-4--the-hallucination-we-caught-for-real" class="hash-link" aria-label="Direct link to Step 4 — The hallucination we caught (for real)" title="Direct link to Step 4 — The hallucination we caught (for real)" translate="no">​</a></h2>
<p>A good RAG must refuse what the docs don't cover. Probe it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> localhost:8000/ask </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"question": "Does WEC offer a free GPU tier for students?"}'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> json.tool</span><br></div></code></pre></div></div>
<p>Our first version (the <code>get_text(" ")</code> scraper) answered:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (first version — spot the bug)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">"No, WEC does not offer a free GPU tier specifically for students. The pricing for</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">GPU hourly is listed as $0.66/GPU-hr with a 25% off annual prepay option at</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">$0.10/GB-mo for block storage. There is no mention of a free GPU tier in the</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">provided information."</span><br></div></code></pre></div></div>
<p>The refusal is right. But read the pricing sentence again: <em>"$0.66/GPU-hr <strong>with</strong> a 25% off annual
prepay option <strong>at $0.10/GB-mo</strong> for block storage"</em> — that's three different facts welded into one
false statement, gluing a storage rate onto the GPU prepay option. Check the actual pricing page
and you find they're simply three adjacent items:</p>
<ul>
<li class="">GPU hourly: <strong>$0.66/GPU-hr</strong></li>
<li class="">Annual prepay: <strong>25% off</strong></li>
<li class="">Block storage: <strong>$0.10/GB-mo</strong></li>
</ul>
<p>The model stitched a storage price onto "GPU". Why? Look at the chunk we fed it. Our
space-separated scrape had flattened the pricing <em>cards</em> into one undifferentiated line:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">The chunk we actually embedded (first version)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">... $1.44 /mo Starting at $0.66 /GPU-hr GPU hourly 25 % off Annual prepay $0.10 /GB-mo Block storage ...</span><br></div></code></pre></div></div>
<p><strong>The hallucination was manufactured by our own ingestion.</strong> The model conflated prices because we
destroyed the structure before embedding it. This is the most classic RAG failure there is —
garbage in, garbage out — and you only catch it by probing with questions and <em>reading the
retrieved chunks</em>.</p>
<p><span class="zoomImage__wrap"><img alt="The subtle hallucination — three price cards welded into one claim" src="https://development-wec.wiline.com/docs/assets/images/rag-hallucination-18e9fb70cb217a347f31057d36b774e8.png" width="947" height="525" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> The bug in the wild: correct refusal, garbled pricing — a storage rate glued onto the
GPU prepay option. The sources look perfectly plausible. This is why "it cites sources" is not the
same as "it's grounded."</p>
<p>Two fixes, both already in the code above:</p>
<ol>
<li class=""><strong>Structure-aware extraction</strong> — <code>get_text("\n", strip=True)</code> keeps line boundaries, so price
cards stay separate lines instead of one soup.</li>
<li class=""><strong>A hardened generation prompt</strong> — quote numbers verbatim with unit and item; never combine
lines; never derive new numbers from examples. (Before this rule, the model happily <em>computed</em>
a fake "$1.5834/GPU-hour" from a daily-spend example.)</li>
</ol>
<p>After re-ingesting with the fix, the storage-price conflation is gone. Residual ambiguity remains —
the pricing strip still contains fragments like "$1.44/mo" (a compute-instance price) near the GPU
line, and truly fixing that needs table-aware extraction (see <em>What's next</em>). Which is exactly why
the next step exists: <strong>pin the fixed bug so it can never quietly return.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--eval-the-service-like-a-ci-gate">Step 5 — Eval the service like a CI gate<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#step-5--eval-the-service-like-a-ci-gate" class="hash-link" aria-label="Direct link to Step 5 — Eval the service like a CI gate" title="Direct link to Step 5 — Eval the service like a CI gate" translate="no">​</a></h2>
<p>This step took <strong>four rounds of fixes to get an honest number</strong> — and the journey from 39% to 87%
is the best lesson in the series, because almost nothing we fixed along the way was the RAG itself.</p>
<p>We reuse part 3's skill — generate a QA dataset <em>from the corpus itself</em> — and grade the <strong>live
HTTP endpoint</strong> with Promptfoo. The first generator version asked for one question per page with a
<code>key_fact</code> the answer must contain, and got 37/37 pairs back. Some labels already smelled — a vague
<code>"All paid"</code>, menu paths instead of answers — but let's run it naively first and let the failures
teach us. Convert to a Promptfoo dataset with plain substring assertions (<code>icontains:</code> + the key
fact) and point it at the running service. The provider is <strong>HTTP</strong> — we grade the real API,
exactly what a CI gate would do:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'question,__expected'</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'[.question, ("icontains:" + .key_fact)] | @csv'</span><span class="token plain"> app/qa.jsonl</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> eval/tests.csv</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> eval/promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "RAG service eval — QA set generated from the scraped docs + regression"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: https</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      url: http://localhost:8000/ask</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      method: POST</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      headers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        Content-Type: application/json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      body:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        question: "{{question}}"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      transformResponse: json.answer</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - file://tests.csv</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  # regression: the storage-price-as-GPU-price conflation from Step 4</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      question: "What is the pricing model for GPU hourly usage in Edge Cloud?"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - type: not-icontains</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        value: "0.10"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - type: not-icontains</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        value: "GB-mo"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">cd</span><span class="token plain"> </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (development run — the service still had the Step-4 bug)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Results:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ 15 passed (39.47%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✗ 23 failed (60.53%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Duration: 40s (concurrency: 4)</span><br></div></code></pre></div></div>
<p>That 39.5% is what we measured <em>during development</em>, while the Step-4 bug was still live. Re-run
the same naive eval against the <strong>finished</strong> service and it lands much higher — 27/38:</p>
<p><span class="zoomImage__wrap"><img alt="The naive eval against the finished service — 71%" src="https://development-wec.wiline.com/docs/assets/images/rag-eval-icontains-284e4ea4fa5a8f2dec99a5079c84c4f7.png" width="827" height="973" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The same naive eval against the finished service: <strong>71.05%</strong>. Better service, same
noisy labels — the 30-point gap between these two runs is all service quality, and both numbers
mean little until you read the rows.</p>
<p><strong>Read the failures before you panic.</strong> From the development run's 23 failures, most are not RAG
failures:</p>
<table><thead><tr><th>Failure</th><th>What actually happened</th></tr></thead><tbody><tr><td>"Balance due" answer: <em>"the amount that remains due… $0.00"</em></td><td>Correct answer; the label demanded the phrase <code>All paid</code> — <strong>bad label</strong></td></tr><tr><td>Payment Information: gives the exact right menu path</td><td>Correct; label phrasing doesn't substring-match — <strong>brittle assertion</strong></td></tr><tr><td>GPU pricing: <em>"$0.10 per GB-month"</em></td><td><strong>Real failure</strong> — the Step-4 conflation, reproducing systematically</td></tr><tr><td>TTS default format: <em>"not specified in the context"</em></td><td><strong>Real retrieval miss</strong> — the chunk with the default wasn't retrieved</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="upgrade-1--a-judge-instead-of-substrings-it-barely-helps">Upgrade 1 — a judge instead of substrings. It barely helps.<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#upgrade-1--a-judge-instead-of-substrings-it-barely-helps" class="hash-link" aria-label="Direct link to Upgrade 1 — a judge instead of substrings. It barely helps." title="Direct link to Upgrade 1 — a judge instead of substrings. It barely helps." translate="no">​</a></h3>
<p>Substring matching (<code>icontains</code>) was perfect for part 2's single JSON field and is <strong>too brittle
for long-form RAG answers</strong>. The obvious upgrade is part 4's skill: an <strong>LLM judge</strong> grading
semantic consistency (<code>llm-rubric:</code> assertions + a judge model in <code>defaultTest</code>). We did exactly
that — same 37 labels, judge instead of substrings — and got:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (judge, uncurated labels)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Results:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ 17 passed (44.74%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✗ 21 failed (55.26%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Duration: 8m 21s (concurrency: 4)</span><br></div></code></pre></div></div>
<p><strong>39.5% → 44.7%.</strong> Barely moved — because <strong>the judge faithfully enforces bad labels.</strong> Rows with
garbage <code>key_fact</code>s still fail: the reference is wrong, not the answer. A better grader cannot
rescue a bad dataset. The bottleneck was never the assertion type.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="upgrade-2--curate-the-labels-with-a-gate">Upgrade 2 — curate the labels with a gate<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#upgrade-2--curate-the-labels-with-a-gate" class="hash-link" aria-label="Direct link to Upgrade 2 — curate the labels with a gate" title="Direct link to Upgrade 2 — curate the labels with a gate" translate="no">​</a></h3>
<p>So fix the data (part 3's discipline, now enforced by code). Generator v2 demands the label be
<em>the answer</em> — not a menu path, not a section name — and adds a <strong>curation gate</strong>: the <code>key_fact</code>
must exist <strong>verbatim</strong> in the source text, or the row is rejected on the spot:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> app/gen_qa.py </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">"""Generate a QA eval set from the indexed docs — v2, curated labels."""</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">import os, json, chromadb</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">from openai import OpenAI</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">wec = OpenAI(base_url="https://inference.wiline.com/v1",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">             api_key=os.environ["WEC_API_KEY"], timeout=60)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">db = chromadb.PersistentClient(path="/data")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">col = db.get_or_create_collection("wec-docs")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">data = col.get(include=["documents", "metadatas"])</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">pages = {}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">for doc, meta in zip(data["documents"], data["metadatas"]):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    pages.setdefault(meta["url"], []).append(doc)</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">kept, skipped = 0, 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">with open("/srv/app/qa.jsonl", "w") as f:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    for url, chunks in sorted(pages.items()):</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        ctx = "\n".join(chunks[:2])[:2000]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        resp = wec.chat.completions.create(</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            model="Qwen2.5-3B-Instruct", temperature=0.3,</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            messages=[{"role": "user", "content":</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "From this documentation excerpt, write ONE question a customer would ask, "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "and the fact that ANSWERS it. Return ONLY JSON with keys:\n"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "question (string), key_fact (string).\n"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "Rules for key_fact: it must be THE ANSWER to the question (not a menu path, "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "not a section name), a short phrase copied VERBATIM from the excerpt, "</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                "max 8 words.\n\n"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                f"Excerpt:\n{ctx}"}],</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        )</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        raw = resp.choices[0].message.content</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        try:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            start, end = raw.index("{"), raw.rindex("}") + 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            row = json.loads(raw[start:end])</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            # curation gate: the label must literally exist in the source text</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            if row["key_fact"].lower().strip() not in ctx.lower():</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                print(f"SKIP {url.split('/docs/')[1]}: key_fact not verbatim in source")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                skipped += 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">                continue</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            row["url"] = url</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            f.write(json.dumps(row) + "\n")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            kept += 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            print(f"ok   {url.split('/docs/')[1]}: {row['question'][:50]} -&gt; {row['key_fact'][:40]}")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        except Exception as e:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            print(f"SKIP {url}: {e}")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">            skipped += 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">print(f"done -&gt; {kept} kept, {skipped} rejected by curation gate")</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> rag-api python </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> app.gen_qa</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (abridged)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ok   cloud_portal/management/billing/how_billing_works/: What is the starting point ... -&gt; How Billing Works</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SKIP cloud_portal/management/billing/overview/: key_fact not verbatim in source</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ok   cloud_portal/platform/inference/api/wiline-edge-cloud-inference-api/: What is the base URL ... -&gt; https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SKIP cloud_portal/platform/inference/api_keys/: key_fact not verbatim in source</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">done -&gt; 15 kept, 22 rejected by curation gate</span><br></div></code></pre></div></div>
<p><strong>15 kept, 22 rejected.</strong> That rejection rate is the point: more than half of what the generator
produced would have graded the RAG against wrong references. A smaller, trustworthy dataset beats
a bigger, poisoned one.</p>
<p>The generator samples at temperature 0.3, so the exact tally shifts slightly between runs — here's
a later regeneration that kept 16 of 37; the rejection rate stays brutal either way:</p>
<p><span class="zoomImage__wrap"><img alt="The curation gate — verbatim-verified pairs kept, the rest rejected" src="https://development-wec.wiline.com/docs/assets/images/rag-qa-gen-8dfb631c47f8fc4cb9c506fef0f651d2.png" width="946" height="852" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The gate at work: 16 kept, 21 rejected on this run. Every surviving label is provably
in the docs — real answer-facts like <code>audio/mpeg</code> and the API base URL, not menu paths. (The
documented eval runs below use our first curated set: 15 pairs + the regression = 16 tests.)</p>
<p>Rebuild <code>tests.csv</code> with a rubric that gives the judge the question <strong>and</strong> the verified
reference, plus explicit PASS/FAIL rules (different wording is fine; contradiction fails):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'question,__expected'</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'[.question, ("llm-rubric:Question asked: \"" + .question + "\". Reference fact from the official docs: \"" + .key_fact + "\". PASS if the answer correctly addresses the question and does not contradict the reference fact — different wording is fine. FAIL only if the answer is wrong, contradicts the reference, or fails to answer the question.")] | @csv'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">..</span><span class="token plain">/app/qa.jsonl</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> tests.csv</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (curated labels + judge)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Results:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ 12 passed (75.00%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✗ 4 failed (25.00%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Duration: 2m 47s (concurrency: 4)</span><br></div></code></pre></div></div>
<p><strong>75%.</strong> Better — but before celebrating, read the four failures. And here the series' habit pays
off one more time, because <strong>three of them weren't RAG failures either.</strong></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="upgrade-3--the-judge-itself-was-breaking">Upgrade 3 — the judge itself was breaking<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#upgrade-3--the-judge-itself-was-breaking" class="hash-link" aria-label="Direct link to Upgrade 3 — the judge itself was breaking" title="Direct link to Upgrade 3 — the judge itself was breaking" translate="no">​</a></h3>
<p>The three failures whose grader reason was just <em>"No output"</em> all shared two tells:
<code>graderError: true</code>, and a completion of <strong>exactly 1024 tokens</strong> — Promptfoo's default
<code>max_tokens</code>. Our judge is a thinking model: it spent the whole budget reasoning and got <strong>cut off
before emitting the verdict JSON</strong>, which Promptfoo counts as FAIL. The fourth "failure" got a
verdict — but the small judge had literally echoed the schema placeholder (<code>reason: "string"</code>)
instead of grading. Both answers were actually correct.</p>
<p>Two config lines fix it — a bigger judge, and room to think:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">eval/promptfooconfig.yaml (judge block, final)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">defaultTest</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">options</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">provider</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">id</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> openai</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">chat</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">Qwen3.5</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">config</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">apiBaseUrl</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">apiKeyEnvar</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">temperature</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic"># thinking models spend tokens on reasoning before the verdict JSON;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic"># the 1024 default silently truncated the judge and failed 3 tests</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token key atrule" style="color:#00a4db">max_tokens</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">4096</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (final run)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Results:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ 14 passed (87.50%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✗ 2 failed (12.50%)</span><br></div></code></pre></div></div>
<p>And the row that matters most:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">│ What is the pricing model for GPU hourly usage in Edge Cloud?  │ [PASS] ... starting at $0.66/GPU-hour. │</span><br></div></code></pre></div></div>
<p><strong>The regression is green</strong> — the Step-4 conflation is pinned and cannot quietly return. (One
cosmetic note: the 122B judge sometimes keeps its reason text to a terse <code>...</code> — the verdicts
themselves are correct, which is what the gate reads.)</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can gate a live RAG service in CI: generated + gate-curated QA, a judge for semantics,
deterministic regressions for known bugs — and you know how to debug the judge itself.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="upgrade-4--the-875-didnt-survive-a-re-run">Upgrade 4 — the 87.5% didn't survive a re-run<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#upgrade-4--the-875-didnt-survive-a-re-run" class="hash-link" aria-label="Direct link to Upgrade 4 — the 87.5% didn't survive a re-run" title="Direct link to Upgrade 4 — the 87.5% didn't survive a re-run" translate="no">​</a></h3>
<p>One habit this series drills: <strong>re-run before you believe.</strong> We did — and got <strong>75%</strong>, with
<em>different</em> rows failing than before. Billing, which had just passed, now failed with a circular
echo; the TTS answer said "mp3" one run and "unspecified" the next. The score was a lottery
because <strong>the service itself was non-deterministic</strong>: <code>generate()</code> passed no <code>temperature</code>, so
every eval graded a different roll of the dice at the default 1.0.</p>
<p>One line ends the lottery — add to the <code>create()</code> call in <code>generate()</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">        temperature</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain">               </span><span class="token comment" style="color:#999988;font-style:italic"># determinism: same question -&gt; same answer</span><br></div></code></pre></div></div>
<p>Re-run twice. <strong>13/16 — 81.25% — both times, with the same three failures.</strong> The number went
<em>down</em> and that's the win: 87.5% was partly luck (the flaky rows happened to land right that run);
81.25% is reproducible. A reader who re-runs your eval should get your number.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-three-real-failures--finally-actual-rag-bugs">The three real failures — finally, actual RAG bugs<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#the-three-real-failures--finally-actual-rag-bugs" class="hash-link" aria-label="Direct link to The three real failures — finally, actual RAG bugs" title="Direct link to The three real failures — finally, actual RAG bugs" translate="no">​</a></h3>
<p>After four rounds of fixing the <em>measurement</em>, what's left is signal — stable across runs, and
each one is a different <strong>classic RAG failure type</strong>:</p>
<ol>
<li class=""><strong>Subnets ("Total Networks") — retrieval miss, chunk boundary.</strong> The right page ranks, but dump
the top-4 retrieved chunks and none contains the stats block with the answer; the top chunk
starts <strong>mid-word</strong> (<code>"iated with subnets\nData Transfer…"</code>). Fixed-size character chunking
split the fact across a boundary, and the half with the answer doesn't rank. Fix class: larger
overlap, bigger <code>k</code>, or structure-aware chunking.</li>
<li class=""><strong>TTS default format — retrieval miss, fact not in top-k.</strong> The chunk stating the default never
reaches the context. Revealing detail: at temperature 1.0 this row <em>sometimes passed</em> — the
model guessed "mp3" without evidence. Determinism converted a lucky guess into an honest,
consistent failure.</li>
<li class=""><strong>Billing ("How Billing Works") — generation quality.</strong> At temperature 0 the small model
deterministically echoes the source sentence — <em>"This is the starting point for billing…"</em> —
without naming the page. Correct refusal-free retrieval, useless circular answer; the judge
rightly fails it. Fix class: prompt work ("name the page/section you're citing") or a stronger
generation model.</li>
</ol>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="spend-the-signal--one-parameter-chosen-by-the-eval">Spend the signal — one parameter, chosen by the eval<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#spend-the-signal--one-parameter-chosen-by-the-eval" class="hash-link" aria-label="Direct link to Spend the signal — one parameter, chosen by the eval" title="Direct link to Spend the signal — one parameter, chosen by the eval" translate="no">​</a></h3>
<p>A trustworthy eval isn't the goal; it's the <em>map</em>. It just told us two of three failures are the
same class — facts that don't reach the top-4 retrieved chunks. The cheapest fix in that class is
one character, in <code>retrieve()</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">retrieve</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">question</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> k</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">int</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">6</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">     </span><span class="token comment" style="color:#999988;font-style:italic"># was 4 — two misses said "not enough context"</span><br></div></code></pre></div></div>
<p>Re-run. Then re-run again, because that's the habit now:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (both runs)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Results:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ 14 passed (87.50%)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✗ 2 failed (12.50%)</span><br></div></code></pre></div></div>
<p><strong>14/16 — 87.5% — twice, same two failures.</strong> Read what happened with each:</p>
<ul>
<li class=""><strong>TTS default — fixed.</strong> With six chunks the page stating the default reaches the context, and
the service now answers "mp3" <em>with evidence</em>, every run — the lucky guess became a grounded
answer.</li>
<li class=""><strong>Subnets — survived.</strong> Even at <code>k=6</code> the chunk with the stats block doesn't rank: this miss is
<em>semantic</em>, not budgetary — the boundary-mangled text (<code>"iated with subnets…"</code>) embeds poorly.
More <code>k</code> can't fix what chunking broke; this one genuinely needs structure-aware chunking.</li>
<li class=""><strong>Billing — survived, as predicted.</strong> It's a generation-quality failure; no retrieval parameter
was ever going to touch it.</li>
</ul>
<p>The number is back to 87.5% — the same as the lucky run — <strong>but this time it reproduces</strong>, and
every point of it has an explanation.</p>
<p><span class="zoomImage__wrap"><img alt="Final eval — deterministic service, k=6, judge-graded, regression passing" src="https://development-wec.wiline.com/docs/assets/images/rag-eval-final-eb3776d3a5abed5af34d6896bbee06d0.png" width="965" height="976" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> The final run: 14/16, the same two failures every time, regression PASS.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="read-the-final-number-honestly">Read the final number honestly<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#read-the-final-number-honestly" class="hash-link" aria-label="Direct link to Read the final number honestly" title="Direct link to Read the final number honestly" translate="no">​</a></h3>
<p>39.5% → 44.7% → 75% → 87.5% <em>(lucky)</em> → 81.25% <em>(honest)</em> → <strong>87.5% (earned)</strong>. Until the very
last step, the RAG barely changed — what changed was the <strong>measurement</strong>: assertions (substrings →
judge), labels (curation gate), the judge's own config (truncation), determinism (temperature).
Only then did one retrieval parameter, chosen <em>by</em> the eval, actually move quality. That's the
last lesson of the series:</p>
<ul>
<li class=""><strong>Most "failures" were our eval lying to us</strong> — bad labels, brittle asserts, a silently
truncated judge, a dice-rolling service. Read the rows before you touch the RAG.</li>
<li class=""><strong>The honest number was lower than the lucky one — and worth more</strong>, because it reproduces. If
your score changes when you re-run, you don't have a score yet.</li>
<li class=""><strong>Fix the measurement first, then spend its signal.</strong> The same 87.5% appears twice in this arc;
only the second one means something — it survives re-runs and every point has an explanation.</li>
<li class=""><strong>The score is a trend instrument; the rows are the truth.</strong> 87.5% still isn't "the RAG's
quality" — it's this dataset, this judge, this corpus. Pin what must never regress with
deterministic asserts; let the judge track the rest.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-built">What you built<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#what-you-built" class="hash-link" aria-label="Direct link to What you built" title="Direct link to What you built" translate="no">​</a></h2>
<ul>
<li class="">A <strong>Docker-first RAG service</strong>: scrape (sitemap → article extraction) → chunk → <strong>local
embeddings</strong> (fastembed/ONNX) → Chroma → <strong>WEC generation</strong>, behind <code>POST /ask</code> with
deterministic citations.</li>
<li class="">A <strong>real bug story</strong>: probed the negative case, caught a garbled GPU price (a storage rate welded
onto the GPU prepay option), root-caused it to the scraper's flattened structure, fixed ingestion<!-- -->
<ul>
<li class="">hardened the prompt, and <strong>pinned it with a regression test that now passes</strong>.</li>
</ul>
</li>
<li class="">A <strong>CI-shaped eval</strong> of the live endpoint: gate-curated QA (15 verbatim-verified pairs out of 37
generated) + a judge you debugged like any other component — 39.5% → <strong>87.5% reproducible</strong>, by
fixing the <em>measurement</em> first (assertions, labels, judge config, determinism) and then spending
its signal on one eval-chosen retrieval parameter. Two real, nameable failures remain — on
purpose, with fix classes named.</li>
<li class=""><strong>Full observability</strong>: every request a span tree in Langfuse — retrieval and generation
separately debuggable.</li>
</ul>
<p>Parts 1–4 were the toolkit. This is what the toolkit is <em>for</em>.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>RAG, end to end</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="ask-returns-nothing-after-re-indexing"><code>/ask</code> returns nothing after re-indexing<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#ask-returns-nothing-after-re-indexing" class="hash-link" aria-label="Direct link to ask-returns-nothing-after-re-indexing" title="Direct link to ask-returns-nothing-after-re-indexing" translate="no">​</a></h3>
<p>If you drop and recreate the Chroma collection while the API is running, the service holds a
<strong>stale collection handle</strong> and every query throws:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">chromadb.errors.InvalidCollectionException: Collection 9b486740-… does not exist.</span><br></div></code></pre></div></div>
<p>The running process cached the old collection's UUID. Restart the service
(<code>docker compose restart rag-api</code>) — or resolve the collection per-request instead of at startup.</p>
<p><span class="zoomImage__wrap"><img alt="The stale-collection error after re-indexing" src="https://development-wec.wiline.com/docs/assets/images/rag-stale-handle-1639bfb9ef1938577ff551daa191a889.png" width="942" height="529" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> Re-indexing invalidates open handles — a classic "works until you rebuild the index"
production trap.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="failed-to-send-telemetry-event--capture-takes-1-positional-argument"><code>Failed to send telemetry event … capture() takes 1 positional argument</code><a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#failed-to-send-telemetry-event--capture-takes-1-positional-argument" class="hash-link" aria-label="Direct link to failed-to-send-telemetry-event--capture-takes-1-positional-argument" title="Direct link to failed-to-send-telemetry-event--capture-takes-1-positional-argument" translate="no">​</a></h3>
<p>Harmless chromadb/posthog version clash. Set <code>ANONYMIZED_TELEMETRY: "False"</code> in the environment
(already in our compose).</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="judge-failures-with-reason-no-output-and-gradererror-true">Judge failures with reason "No output" (and <code>graderError: true</code>)<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#judge-failures-with-reason-no-output-and-gradererror-true" class="hash-link" aria-label="Direct link to judge-failures-with-reason-no-output-and-gradererror-true" title="Direct link to judge-failures-with-reason-no-output-and-gradererror-true" translate="no">​</a></h3>
<p>Check the grading record's completion tokens: if it's <strong>exactly 1024</strong> — Promptfoo's default
<code>max_tokens</code> — your thinking-model judge spent the whole budget reasoning and was cut off before
the verdict JSON. Set <code>max_tokens: 4096</code> on the judge provider. Related tell: a small judge
returning <code>reason: "string"</code> is echoing the schema placeholder instead of grading — use a bigger
judge.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-judge-eval-is-slow-or-verdicts-look-random">The judge eval is slow or verdicts look random<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#the-judge-eval-is-slow-or-verdicts-look-random" class="hash-link" aria-label="Direct link to The judge eval is slow or verdicts look random" title="Direct link to The judge eval is slow or verdicts look random" translate="no">​</a></h3>
<p>Reasoning judges are slow (~minutes for a dozen rows) and imperfect — expect occasional verdict
flips between runs. Keep <strong>deterministic assertions for known bugs</strong> (they're free and never flip)
and treat the judge score as a trend, not a truth. If a judge row hangs entirely, it's usually a
transient network hiccup, not your config — retry before debugging.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="docker-build-fails-with-no-space-left-on-device">Docker build fails with "no space left on device"<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#docker-build-fails-with-no-space-left-on-device" class="hash-link" aria-label="Direct link to Docker build fails with &quot;no space left on device&quot;" title="Direct link to Docker build fails with &quot;no space left on device&quot;" translate="no">​</a></h3>
<p>The image needs ~1 GB (chromadb + onnxruntime are the heavy layers). Check <code>df -h /</code> first and
<code>docker system df</code> for reclaimable space — but <strong>never prune blindly on a shared box</strong>: "reclaimable"
lies when other projects' containers are running.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>The honest limitations are the roadmap, and each one traces to a failure you watched happen:
<strong>structure-aware chunking</strong> (the subnets miss survived <code>k=6</code> — the boundary-mangled chunk embeds
poorly, so no retrieval budget rescues it), <strong>prompt or model work on generation</strong> (the billing
echo: force the model to name the page it's citing, or use a stronger generator), <strong>table-aware
extraction</strong> (the pricing strip is still fragment soup — the regression passes, but "$1.44/mo"
still bleeds near the GPU line; add a <em>positive</em> assert for the correct figure, not just
<code>not-icontains</code> for the wrong ones), and <strong>judge calibration</strong> (grade the judge against a handful
of human verdicts). Each one moves the pass rate for a reason you can name — which, after five
parts, is the whole point: <em>you don't guess whether your AI works. You measure it, watch it, and
make it prove itself.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.trychroma.com/" target="_blank" rel="noopener noreferrer" class="">Chroma docs — persistent client &amp; collections</a></li>
<li class=""><a href="https://github.com/qdrant/fastembed" target="_blank" rel="noopener noreferrer" class="">fastembed — ONNX embeddings without PyTorch</a></li>
<li class=""><a href="https://www.promptfoo.dev/docs/providers/http/" target="_blank" rel="noopener noreferrer" class="">Promptfoo — HTTP provider</a></li>
<li class=""><a href="https://langfuse.com/docs/sdk/python/decorators" target="_blank" rel="noopener noreferrer" class="">Langfuse — Python decorators</a></li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>rag</category>
            <category>embeddings</category>
            <category>chromadb</category>
            <category>docker</category>
            <category>evals</category>
            <category>promptfoo</category>
            <category>langfuse</category>
            <category>inference</category>
        </item>
        <item>
            <title><![CDATA[Catch what your tests miss: observe and score your WEC app in production with Langfuse]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/</guid>
            <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[CI evals pass on a fixed test set — but production sends inputs you never tested. Self-host Langfuse on a WEC Instance, trace every real call, auto-score live traffic with an LLM judge, drill into the exact step that broke, and feed failures back to make your evals stronger. Every command — and every dead end — is real.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><svg xmlns="http://www.w3.org/2000/svg" width="2255" height="527" fill="none" viewBox="0 0 2255 527" class="tutorialHero__langfuse"><path fill="#1B1917" d="M652.669 116.433c0-10.261-7.683-17.956-17.926-17.956H607V60h87.923v316.366h-42.254zM804.056 379.786c-39.693 0-72.131-27.362-72.131-68.831 0-41.042 28.596-69.686 84.935-69.686h39.693c7.256 0 12.805-5.558 12.805-12.826v-8.55c0-25.651-20.487-38.905-40.547-38.905-18.353 0-33.291 8.123-41.828 26.934h-45.668c11.95-44.462 46.095-65.41 88.349-65.41 40.547 0 82.374 22.231 82.374 77.808v156.046h-42.68v-14.109c0-5.13-5.549-7.695-9.817-4.702-16.646 11.97-32.438 22.231-55.485 22.231m5.975-38.477c18.78 0 34.572-9.406 52.498-27.362 4.695-4.702 6.829-10.688 6.829-17.1v-6.413c0-7.268-5.549-12.826-12.805-12.826H815.58c-27.743 0-40.974 12.398-40.974 31.637 0 17.956 12.377 32.064 35.425 32.064M961.855 376.366V147.642h42.255v19.666c0 5.13 6.4 6.84 10.24 2.992 13.23-12.825 31.59-27.788 60.61-27.788 36.71 0 70.85 23.086 70.85 75.671v158.183h-42.25V227.161c0-29.499-17.93-45.745-39.7-45.745-20.91 0-35 11.971-49.93 29.499-7.26 8.978-9.82 19.238-9.82 30.354v135.097zM1287.48 467c-52.5 0-85.79-24.796-95.61-63.273h46.1c7.25 15.818 20.06 25.651 45.67 25.651 34.14 0 55.48-20.093 55.48-65.838v-7.268c0-5.13-4.27-7.695-9.39-3.42-14.08 12.398-32.01 20.093-49.08 20.093-58.05 0-96.46-44.889-96.46-115.003 0-70.113 44.39-115.43 98.17-115.43 15.79 0 30.3 4.275 44.38 14.963 5.98 4.275 12.38.855 12.38-5.986v-3.847h42.26V363.54c0 72.678-43.97 103.46-93.9 103.46m-2.56-132.959c19.2 0 33.29-8.55 44.81-20.949 7.26-8.122 9.39-13.68 9.39-26.506v-61.563c0-12.826-2.13-20.948-10.67-29.071-9.39-8.978-22.62-14.964-39.69-14.964-35 0-61.89 29.072-61.89 76.954 0 47.883 24.76 76.099 58.05 76.099M1455.92 199.372c0-7.268-5.97-13.253-13.23-13.253h-32.44v-38.477h32.44c7.26 0 13.23-5.985 13.23-13.253v-5.986c0-45.744 23.48-68.403 69.15-68.403h29.02v38.477h-29.45c-17.5 0-26.46 9.833-26.46 29.926v5.986c0 7.268 5.97 13.253 13.23 13.253h42.68v38.477h-42.68c-7.26 0-13.23 5.985-13.23 13.253v176.994h-42.26zM1652.02 381.496c-35.85 0-69.14-23.086-69.14-75.671V147.642h42.25v150.06c0 29.499 17.07 44.889 37.13 44.889 21.77 0 35.85-11.97 50.79-29.499 7.26-8.977 9.82-19.238 9.82-30.354V147.642h42.25v228.724h-42.25V356.7c0-5.131-6.4-6.841-10.24-2.993-13.24 12.826-31.59 27.789-60.61 27.789M1893.57 381.496c-38.84 0-79.39-19.239-90.06-65.838h43.54c6.4 17.528 23.9 29.498 44.81 29.498 23.05 0 37.13-13.68 37.13-30.353 0-16.246-11.09-25.224-28.59-30.354l-36.28-10.261c-31.58-8.978-55.06-29.499-55.06-64.556 0-38.049 35.43-67.12 75.55-67.12 32.01 0 70.85 14.535 81.09 65.41h-40.55c-5.55-17.528-20.06-29.071-40.54-29.071-20.06 0-34.58 12.398-34.58 28.216 0 13.253 8.11 23.086 27.32 28.644l34.15 9.833c32.43 9.406 58.47 29.072 58.47 66.693 0 39.332-34.15 69.259-76.4 69.259M2098.54 381.496c-61.46 0-102.01-51.73-102.01-119.706s43.11-119.278 101.58-119.278c63.6 0 96.89 51.302 96.89 109.872v23.087h-144.26c-5.98 0-8.54 3.847-7.26 13.68 4.7 32.064 30.31 54.295 55.49 54.295 18.78 0 35-9.405 45.67-27.788h44.81c-16.22 40.187-49.94 65.838-90.91 65.838m43.11-141.51c6.83 0 9.39-3.42 7.68-14.108-4.69-26.506-24.33-45.317-51.22-45.317-25.6 0-47.37 18.811-54.2 45.745-2.56 9.833.85 13.68 6.83 13.68z"></path><path fill="#FF5D5F" d="m286.292 286.105 34.597 27.791s26.473-19.661 45.941-22.545c20.418-3.025 42.202 8.359 62.388 21.93 30.489 20.498 56.149 46.508 56.149 46.508l30.06-29.493s-82.879-89.795-148.597-81.672c-43.105 5.328-80.538 37.481-80.538 37.481"></path><path fill="#4E9CFF" d="M88.358 114.862 60 146.056s79.009 73.732 141.224 73.732c28.358 0 67.684-22.216 101.523-51.079 19.283-16.448 40.835-35.13 62.388-35.13 14.487 0 33.594 7.673 51.612 27.824 0 0 11.63-6.974 18.716-11.985 6.228-4.404 15.479-11.91 15.479-11.91-25.918-27.663-63.407-47.883-85.807-45.9-36.299.005-62.388 22.601-94.717 48.735s-45.94 36.907-69.194 36.907c-39.134 0-112.866-62.388-112.866-62.388M88.358 352.463 60 321.269s79.009-73.732 141.224-73.732c28.358 0 67.684 22.216 101.523 51.079 19.283 16.448 40.835 35.13 62.388 35.13 14.556 0 33.518-7.989 51.612-28.358 0 0 10.877 6.705 17.582 11.344 6.894 4.769 17.015 12.655 17.015 12.655-25.931 27.883-63.693 48.323-86.209 46.33-36.299-.005-57.851-19.24-90.179-45.374-32.329-26.133-50.478-40.268-73.732-40.268-39.134 0-112.866 62.388-112.866 62.388M458.142 185.149c-7.378 5.1-19.283 12.478-19.283 12.478s6.806 14.746 6.806 34.597-6.239 36.866-6.239 36.866 10.688 6.675 17.582 11.343c7.162 4.849 18.149 13.045 18.149 13.045s13.045-27.224 13.045-61.254-13.045-59.552-13.045-59.552-10.236 7.792-17.015 12.477"></path><path fill="#FF5D5F" d="m287.995 180.612 32.895-27.224s26.473 19.046 45.941 21.93c20.417 3.026 42.202-8.359 62.388-21.93 30.489-20.498 56.149-46.507 56.149-46.507l30.06 29.492s-82.879 89.795-148.597 81.672c-43.105-5.328-78.836-37.433-78.836-37.433M208.601 91c42.538 0 78.264 36.299 78.264 36.299s-9.941 7.832-16.448 13.045c-6.777 5.429-17.582 14.179-17.582 14.179s-18.711-19.851-44.234-19.851c-10.465 0-24.066 6.286-38.567 18.716-11.188 9.591-22.829 21.514-30.627 36.299-6.743 12.784-10.42 27.85-10.776 43.672-.446 19.873 6.597 40.704 18.149 57.283 7.743 11.112 16.983 19.474 26.657 26.657 12.555 9.322 25.648 15.881 35.164 15.881 10.166 0 19.306-3.533 26.09-6.806 10.776-6.239 19.278-13.612 19.278-13.612l33.463 27.791s-13.612 13.612-32.323 23.821c-12.091 5.963-27.632 11.91-46.508 11.91-18.862 0-40.767-10.022-61.254-25.522-13.244-10.021-26.225-21.895-36.298-36.299-16.51-23.607-25.017-52.328-24.96-81.104.057-29.136 9.451-57.993 26.094-81.672C138.273 117.657 176.86 91 208.601 91"></path></svg><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">4</span><span class="skillTracker__skill" data-state="current">Observe &amp; score production</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>A customer says your support bot promised them a refund policy that doesn't exist. Your feature
made <strong>two</strong> LLM calls — classify, then reply. Which one invented it? If you can't answer that,
your app is a black box — and this guide fixes exactly that.</p>
<p>You can now prove a model works (<a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">part 1</a>),
make its output machine-reliable (<a class="" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/">part 2</a>),
and generate a real test set to check it against
(<a class="" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/">part 3</a>). But all of that runs <strong>offline,
in CI, on inputs you chose.</strong> Production doesn't play along.</p>
<p>This guide closes the gap. We'll self-host <strong>Langfuse</strong> — the open-source, self-hostable
alternative to LangSmith — trace every real call, <strong>auto-score live traffic with an LLM judge</strong>,
drill into the exact step that breaks, and <strong>feed failures back</strong> so your part-3 dataset gets
stronger. Offline eval tells you it worked on your test set; this tells you it works in the wild.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-skill-this-adds">The skill this adds<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#the-skill-this-adds" class="hash-link" aria-label="Direct link to The skill this adds" title="Direct link to The skill this adds" translate="no">​</a></h2>
<p>By the end you can take any WEC feature and: <strong>see inside every call, score quality on real
traffic automatically, find the exact call that broke and why, and turn production failures into
new test cases.</strong> That's the difference between "it passed CI" and "it's holding up in production."</p>
<p>We'll keep the running example from part 3 — a <strong>support-ticket classifier</strong> on the WEC Inference
API — and put it under observation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-were-building">What we're building<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#what-were-building" class="hash-link" aria-label="Direct link to What we're building" title="Direct link to What we're building" translate="no">​</a></h2>
<p>Your app calls the WEC API as usual; Langfuse (self-hosted) quietly captures <strong>every call as a
trace</strong>, an LLM judge <strong>scores</strong> each one, and the failures flow <strong>back into your part-3
dataset</strong> — so production keeps making your evals stronger:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-langfuse-and-when-to-pick-something-lighter">Why Langfuse (and when to pick something lighter)<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#why-langfuse-and-when-to-pick-something-lighter" class="hash-link" aria-label="Direct link to Why Langfuse (and when to pick something lighter)" title="Direct link to Why Langfuse (and when to pick something lighter)" translate="no">​</a></h2>
<p>We use Langfuse because it's the open-source leader for LLM observability (MIT-licensed,
self-hostable), its SDK is <strong>OpenAI-compatible</strong> so it points at the WEC API unchanged, and — the
reason it fits <em>this</em> series — it has <strong>datasets, scoring, and LLM-as-judge evaluators built in.</strong>
That's what lets your part-3 dataset and the "score production, feed failures back" loop be
first-class instead of bolted on.</p>
<p>The trade-off: the production stack is heavy (Postgres + ClickHouse). If you don't need the eval
features and want lighter, reasonable alternatives are <strong>Arize Phoenix</strong> (OpenTelemetry-native,
strong for offline eval), <strong>Helicone</strong> (a one-line proxy — but no evals), or <strong>OpenLIT</strong>
(single-binary, OTEL-native). For an eval-driven workflow like this one, the extra containers earn
their keep; for plain tracing, one of those may suit you better.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--self-host-langfuse-on-a-wec-instance">Step 1 — Self-host Langfuse on a WEC Instance<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-1--self-host-langfuse-on-a-wec-instance" class="hash-link" aria-label="Direct link to Step 1 — Self-host Langfuse on a WEC Instance" title="Direct link to Step 1 — Self-host Langfuse on a WEC Instance" translate="no">​</a></h2>
<p>Langfuse's self-host stack is six services (web + worker, ClickHouse, Postgres, Redis, object
store), so give it a WEC Instance with <strong>~15 GB disk and 4 GB+ RAM</strong> — don't cram it onto a box
that's already busy.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">git</span><span class="token plain"> clone https://github.com/langfuse/langfuse.git </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> langfuse</span><br></div></code></pre></div></div>
<p>Generate three secrets and drop them in a <code>.env</code> (the <code>ENCRYPTION_KEY</code> <strong>must</strong> be hex, or the
web container won't boot):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> .env </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">EOF</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">NEXTAUTH_SECRET=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-base64</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">32</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">SALT=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-base64</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">32</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">ENCRYPTION_KEY=</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable" style="color:#36acaa">openssl rand </span><span class="token string variable parameter variable" style="color:#36acaa">-hex</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable number" style="color:#36acaa">32</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">NEXTAUTH_URL=http://&lt;your-instance-ip&gt;:3000</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The Langfuse stack up — six services healthy" src="https://development-wec.wiline.com/docs/assets/images/langfuse-deploy-ps-597bc541cd07b60e848e6c0dfe6246fc.png" width="2524" height="422" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> <code>docker compose ps</code> — all six services (web, worker, ClickHouse, Postgres, Redis, MinIO) up and healthy.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Deploying next to other services</div><div class="admonitionContent_BuS1"><p>If the box already runs something on port 3000 or 5432, Langfuse won't start ("address already
in use"). The backing databases don't need host ports at all — edit <code>docker-compose.yml</code> to move
<code>langfuse-web</code> to a free host port (e.g. <code>3001:3000</code>) and drop the Postgres host mapping. Keeping
your databases off the host network is good practice anyway. See <a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#ts-ports" class="">Troubleshooting</a>.</p></div></div>
<p>Open <code>http://&lt;instance-ip&gt;:3000</code>, create an account (first user is the owner), then an
<strong>Organization → Project</strong>, and under <strong>Settings → API Keys</strong> create a key pair
(<code>pk-lf-…</code> / <code>sk-lf-…</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--instrument-the-classifier-so-every-call-is-traced">Step 2 — Instrument the classifier so every call is traced<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-2--instrument-the-classifier-so-every-call-is-traced" class="hash-link" aria-label="Direct link to Step 2 — Instrument the classifier so every call is traced" title="Direct link to Step 2 — Instrument the classifier so every call is traced" translate="no">​</a></h2>
<p>Point the Langfuse SDK at your instance and use its <strong>OpenAI drop-in</strong> — it auto-traces any
OpenAI-compatible call, including the WEC Inference API, with zero extra code around each request:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">python3 </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> venv ~/lf-env </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">source</span><span class="token plain"> ~/lf-env/bin/activate</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">pip </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> langfuse openai</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_PUBLIC_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"pk-lf-…"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_SECRET_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"sk-lf-…"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">LANGFUSE_HOST</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"http://&lt;instance-ip&gt;:3000"</span><span class="token plain">   </span><span class="token comment" style="color:#999988;font-style:italic"># use the port you exposed — 3001 if you remapped in Step 1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"sk-your-wec-key"</span><br></div></code></pre></div></div>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">app.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> openai          </span><span class="token comment" style="color:#999988;font-style:italic"># drop-in: auto-traces every call</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> get_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">client </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> openai</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">OpenAI</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    base_url</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"https://inference.wiline.com/v1"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    api_key</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">environ</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"WEC_API_KEY"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    timeout</span><span class="token operator" style="color:#393A34">=</span><span class="token number" style="color:#36acaa">30</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain">                             </span><span class="token comment" style="color:#999988;font-style:italic"># ALWAYS set this — a slow backend hangs forever otherwise</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">classify</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ticket</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    resp </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">chat</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">completions</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">create</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        name</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"ticket-classify"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain">             </span><span class="token comment" style="color:#999988;font-style:italic"># names the trace in Langfuse</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        messages</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"Classify this support ticket. Return ONLY JSON with keys "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string" style="color:#e3116c">"category (billing/technical/account/other), priority (low/medium/high), "</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string-interpolation string" style="color:#e3116c">f"summary. Ticket: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">ticket</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> resp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">choices</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> __name__ </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__main__"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">classify</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"I was charged twice this month."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    get_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                    </span><span class="token comment" style="color:#999988;font-style:italic"># push traces before the process exits</span><br></div></code></pre></div></div>
<p>Run it, then open <strong>Tracing</strong> — the call shows up in seconds with input, output, latency, token
counts, and (once we add pricing) cost.</p>
<p><span class="zoomImage__wrap"><img alt="A single trace — input, output, latency, and token counts" src="https://development-wec.wiline.com/docs/assets/images/langfuse-first-trace-f013e891fddc5d65b3525d5e1ad59fb7.png" width="2532" height="1438" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> One <code>ticket-classify</code> call in Langfuse: the prompt, the JSON output, latency (~1.2s),
and token counts — captured with zero code wrapped around the request.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Set a timeout</div><div class="admonitionContent_BuS1"><p>The OpenAI client has <strong>no default timeout</strong>. If the model is slow or the backend stalls, your
app hangs indefinitely — no error, no trace. <code>timeout=30</code> turns that into a fast, visible failure.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="from-one-call-to-a-real-pipeline--the-span-tree">From one call to a real pipeline — the span tree<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#from-one-call-to-a-real-pipeline--the-span-tree" class="hash-link" aria-label="Direct link to From one call to a real pipeline — the span tree" title="Direct link to From one call to a real pipeline — the span tree" translate="no">​</a></h3>
<p>One call is easy to log; you don't need Langfuse for that. Tracing earns its keep on <strong>multi-step</strong>
features, where it shows the <strong>whole chain as one tree</strong> so you can see <em>which step</em> broke. Let's make
the feature realistic — <strong>classify the ticket, then draft a reply from that category</strong> (two WEC calls) —
and group them into a single trace with the <code>@observe</code> decorator:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">pipeline.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> observe</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> get_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> app </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> classify              </span><span class="token comment" style="color:#999988;font-style:italic"># reuse the traced WEC client + classifier</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">draft_reply</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ticket</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> category</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    resp </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">chat</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">completions</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">create</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        model</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"Qwen2.5-3B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        name</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"draft-reply"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        messages</span><span class="token operator" style="color:#393A34">=</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">{</span><span class="token string" style="color:#e3116c">"role"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"user"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"content"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token string-interpolation string" style="color:#e3116c">f"A customer sent this </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">category</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c"> ticket. Write a short, helpful reply.\nTicket: </span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">{</span><span class="token string-interpolation interpolation">ticket</span><span class="token string-interpolation interpolation punctuation" style="color:#393A34">}</span><span class="token string-interpolation string" style="color:#e3116c">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> resp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">choices</span><span class="token punctuation" style="color:#393A34">[</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">message</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">content</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token decorator annotation punctuation" style="color:#393A34">@observe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                                     </span><span class="token comment" style="color:#999988;font-style:italic"># groups everything below into ONE trace</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">def</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">handle_ticket</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ticket</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> </span><span class="token builtin">str</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    category </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> classify</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ticket</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">                </span><span class="token comment" style="color:#999988;font-style:italic"># child span 1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    reply </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> draft_reply</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ticket</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> category</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">      </span><span class="token comment" style="color:#999988;font-style:italic"># child span 2</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> reply</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> __name__ </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"__main__"</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">print</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">handle_ticket</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"I was charged twice this month."</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    get_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Open that trace and you get a <strong>tree</strong>: <code>handle_ticket</code> → <code>ticket-classify</code> → <code>draft-reply</code>, each
child with its own input, output, latency, and tokens. Now a failure has an <strong>address</strong> — you can
point at the exact step that broke instead of guessing (we use this in Step 5).</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>see inside any LLM call in production</strong> — input, output, latency, tokens — without
writing a line of logging code.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--replay-your-part-3-dataset-through-it">Step 3 — Replay your part-3 dataset through it<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-3--replay-your-part-3-dataset-through-it" class="hash-link" aria-label="Direct link to Step 3 — Replay your part-3 dataset through it" title="Direct link to Step 3 — Replay your part-3 dataset through it" translate="no">​</a></h2>
<p>To get real signal (not one call), push your <strong>part-3 dataset</strong> through the classifier so the
dashboard has traffic to analyze:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">replay.py</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> os</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">openai </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> openai</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> langfuse </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> get_client</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> app </span><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> classify   </span><span class="token comment" style="color:#999988;font-style:italic"># reuse the traced function</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> line </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token builtin">open</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">os</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">path</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">expanduser</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"~/tickets_dataset.jsonl"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> line</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">strip</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        classify</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">json</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">loads</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">line</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">[</span><span class="token string" style="color:#e3116c">"ticket"</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">get_client</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Now the <strong>Home dashboard</strong> shows traces over time, a latency distribution (p50/p95), tokens by
model, and a cost total — the "watch it in production" view.</p>
<p><span class="zoomImage__wrap"><img alt="The Langfuse dashboard populated with replayed traffic" src="https://development-wec.wiline.com/docs/assets/images/langfuse-scores-dashboard-e46938c8695864fa98dc8afa0e488b3a.png" width="2526" height="1432" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> After replaying the part-3 dataset: traces over time, a latency distribution, tokens
by model, and a cost total — the "watch it in production" view.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-cost-real-for-wec-models">Make cost real for WEC models<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#make-cost-real-for-wec-models" class="hash-link" aria-label="Direct link to Make cost real for WEC models" title="Direct link to Make cost real for WEC models" translate="no">​</a></h3>
<p>Langfuse auto-computes cost for models it knows (OpenAI, Anthropic), but the WEC Inference API
returns no dollar cost and Langfuse doesn't know WEC's model prices — so cost shows <code>$0</code>. This is
the same gap we hit in <a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">part 1</a>. Fix it once:
<strong>Settings → Models → add a model</strong> matching <code>Qwen2.5-3B-Instruct</code> with your per-token input/output
prices. Now every trace and the dashboard show real spend — the cost-tracking that Promptfoo
couldn't give us.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--auto-score-live-traffic-with-an-llm-judge">Step 4 — Auto-score live traffic with an LLM judge<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-4--auto-score-live-traffic-with-an-llm-judge" class="hash-link" aria-label="Direct link to Step 4 — Auto-score live traffic with an LLM judge" title="Direct link to Step 4 — Auto-score live traffic with an LLM judge" translate="no">​</a></h2>
<p>CI eval checks a fixed set. Here we score <strong>every real call</strong> automatically with an LLM-as-judge.
This step looks like pure configuration — it's also where we hit, and solved, the best bug of the
series.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="wire-up-the-judge">Wire up the judge<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#wire-up-the-judge" class="hash-link" aria-label="Direct link to Wire up the judge" title="Direct link to Wire up the judge" translate="no">​</a></h3>
<p>Create the evaluator via <strong>Evaluators → + New evaluator</strong>. It's a 3-step wizard:</p>
<p><strong>1. Select Evaluator</strong> — pick the <strong>Correctness</strong> LLM-as-judge template.</p>
<p><strong>2. Set up LLM connection</strong> — the judge needs a model to call, and there's none yet, so click
<strong>+ Add LLM Connection</strong>. The dialog has more fields than you'd expect — here's exactly what each
one needs:</p>
<ul>
<li class=""><strong>LLM adapter:</strong> <code>openai</code></li>
<li class=""><strong>Provider name:</strong> any label <em>without colons</em> (e.g. <code>wec-judge</code>) — just the connection's name in Langfuse</li>
<li class=""><strong>API Key:</strong> your WEC key</li>
<li class="">Click <strong>Show advanced settings</strong>:<!-- -->
<ul>
<li class=""><strong>API Base URL:</strong> <code>https://inference.wiline.com/v1</code> — <strong>critical</strong>; left blank it hits real OpenAI</li>
<li class=""><strong>Use Responses API:</strong> leave <strong>off</strong> (WEC speaks Chat Completions)</li>
<li class=""><strong>Extra Headers:</strong> none — skip</li>
<li class=""><strong>Enable default models:</strong> turn <strong>off</strong> (you don't want OpenAI's model list here)</li>
<li class=""><strong>Custom models → Add custom model name:</strong> <code>Qwen3.5:9B</code> — the model choice matters a lot
here, as you're about to see</li>
</ul>
</li>
<li class=""><strong>Create connection</strong>, then select provider <strong><code>wec-judge</code></strong> and model <strong><code>Qwen3.5:9B</code></strong> → <strong>Save</strong>.</li>
</ul>
<p><span class="zoomImage__wrap"><img alt="The New LLM Connection dialog — WEC base URL, default models off, a custom WEC model added" src="https://development-wec.wiline.com/docs/assets/images/langfuse-llm-connection-e83545064c144db968575c7d85d0c809.png" width="2520" height="1274" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> The New LLM Connection dialog: adapter <code>openai</code>, the WEC API Base URL, default
models switched off, and a custom model added — this is what points the judge at WEC instead of
real OpenAI.</p>
<p><strong>3. Run Evaluator</strong> — add a filter <strong>Name = <code>ticket-classify</code></strong>, toggle <strong>Run on live incoming
observations</strong> so it scores new traffic, and <strong>Execute</strong>.</p>
<p><span class="zoomImage__wrap"><img alt="The LLM-as-judge evaluator configuration" src="https://development-wec.wiline.com/docs/assets/images/langfuse-evaluator-config-328cb01c6a061467eca223c1d7266d31.png" width="2526" height="1436" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> The Correctness evaluator — score name, the <code>ticket-classify</code> filter, and "run on
live incoming observations."</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="then-no-scores-debugging-the-120-second-timeout">Then: no scores. Debugging the 120-second timeout<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#then-no-scores-debugging-the-120-second-timeout" class="hash-link" aria-label="Direct link to Then: no scores. Debugging the 120-second timeout" title="Direct link to Then: no scores. Debugging the 120-second timeout" translate="no">​</a></h3>
<p>We configured everything, sent fresh traffic… and nothing. No score on any trace. Here's how we
tracked it down — you may hit the same wall:</p>
<ol>
<li class=""><strong>Check the evaluator's log.</strong> <strong>Evaluators → your evaluator → Logs</strong> had the real error:
<code>Request timed out after 120000ms</code>. Every run, exactly 120s — so the judge <em>was</em> firing, its
LLM call just never came back.</li>
<li class=""><strong>Suspect the model.</strong> Our first judge, <code>Qwen2.5-3B-Instruct</code>, rejected tool calls outright
(instant 400 — a missing vLLM config on the backend; more on that below). We switched to
<code>Qwen3.5:9B</code>, which answers a direct tool call in ~3s. <strong>Still timed out.</strong></li>
<li class=""><strong>Suspect the network.</strong> <code>curl</code> from the host: 200 in half a second. The same judge-style
request from <em>inside the worker container</em>: ~3s. Model, network, container — all fine.</li>
<li class=""><strong>Reproduce what Langfuse actually sends.</strong> The judge doesn't call the API like our curl — it
uses <em>structured output</em> (<code>response_format</code>) so the score comes back machine-readable. Replaying
that exact call shape exposed the problem: on this backend, <strong>judge-style requests to the
reasoning model are wildly slow and unpredictable</strong> — the identical request ranged from 3 to
95 seconds across runs (the model "thinks" at length, and JSON mode adds overhead). With the
judge's longer real prompt, runs blew past the 120s default. <strong>Nothing was broken — it was just
slower than the timeout.</strong></li>
</ol>
<p>The fix is two lines of configuration. Langfuse reads its LLM timeout from
<code>LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS</code> (default 120000). Give it room:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">docker-compose.override.yml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">services</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">langfuse-worker</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"600000"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">langfuse-web</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">environment</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token key atrule" style="color:#00a4db">LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"600000"</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> langfuse-worker langfuse-web</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># verify it landed inside the container:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token builtin class-name">exec</span><span class="token plain"> langfuse-worker </span><span class="token function" style="color:#d73a49">env</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> TIMEOUT</span><br></div></code></pre></div></div>
<p>Send a fresh trace through <code>app.py</code>, wait a couple of minutes, and:</p>
<p><span class="zoomImage__wrap"><img alt="The Correctness score attached to a real trace" src="https://development-wec.wiline.com/docs/assets/images/langfuse-score-on-trace-99653e359fac7e126dc097103602a4e5.png" width="2520" height="1424" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> There it is — <strong>Correctness: 1.00</strong> attached to a live <code>ticket-classify</code> trace, with
the judge's reasoning a click away. Every new call now gets scored automatically.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>auto-grade real production traffic</strong> — and debug an evaluator that silently produces
nothing: check its Logs, isolate model vs. network vs. container, match the timeout to reality.</p></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Two constraints to know about</div><div class="admonitionContent_BuS1"><p><strong>The judge needs tool calling.</strong> If your model returns a 400 on any <code>tools</code> request, that's
usually the serving config, not the model — on vLLM it's the <code>--enable-auto-tool-choice</code> and
<code>--tool-call-parser</code> flags. That was our case: we reported it, the platform team enabled the
flags on <code>Qwen2.5-3B-Instruct</code> the same day, and the 400 disappeared. Only the judge's connection
is affected — your app keeps its model.</p><p><strong>Scoring is slow (~2 min/score) and that's the backend, not you.</strong> The reasoning model "thinks"
at length on judge prompts — worth reporting to your platform team (reasoning budget caps, or a
guided-decoding backend like xgrammar on vLLM). The raised timeout keeps scoring reliable meanwhile.</p></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Tempting shortcut that doesn't work: a small model as judge</div><div class="admonitionContent_BuS1"><p>Once tool calling was enabled on <code>Qwen2.5-3B</code>, we tried it as the judge — scores came back in
<strong>2–10 seconds</strong> instead of ~2 minutes. But they were garbage: the same kind of correct answer got
<strong>1, 1, 0, and 0.05</strong> across four runs, with near-identical rationales attached to opposite
scores. The small model fills the score format perfectly; it just can't actually <em>evaluate</em>.
Mirror image of <a class="" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/">part 3</a>'s finding: the small
model wins at <em>classifying</em> and loses at <em>judging</em> — judging is a reasoning task. Keep the judge
on <code>Qwen3.5:9B</code>: slow but coherent.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--find-the-failure-your-tests-missed">Step 5 — Find the failure your tests missed<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-5--find-the-failure-your-tests-missed" class="hash-link" aria-label="Direct link to Step 5 — Find the failure your tests missed" title="Direct link to Step 5 — Find the failure your tests missed" translate="no">​</a></h2>
<p>This is where the <strong>span tree</strong> pays off. Open a <code>handle_ticket</code> trace and you don't get one opaque
blob — you get the <strong>whole chain as a tree</strong>, each step with its own input, output, latency, and
tokens:</p>
<p><span class="zoomImage__wrap"><img alt="Drilling into a trace — the span tree localizes each step" src="https://development-wec.wiline.com/docs/assets/images/langfuse-failing-trace-8fcb77412fc10f743dad85e747382695.png" width="2530" height="1432" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The <code>handle_ticket</code> tree — <code>handle_ticket → ticket-classify → draft-reply</code> — with
per-step latency and tokens. Every step has an address you can inspect.</p>
<p>That structure is what makes production failures debuggable. Say a customer gets a nonsense reply.
Without tracing you'd only know "the reply was bad." With the tree you expand it and read each step
— for example:</p>
<ul>
<li class=""><strong><code>ticket-classify</code></strong> → <code>category: "billing"</code> ✅ correct — so the classifier isn't the problem.</li>
<li class=""><strong><code>draft-reply</code></strong> → invented a refund policy that doesn't exist ❌ — <strong>the bug is in step 2.</strong></li>
</ul>
<p>You've localized the failure to the <strong>exact step, input, and output</strong> — you'd fix the <code>draft-reply</code>
prompt, not waste time on the classifier. Offline eval tells you the pass rate dropped; the trace
tells you <em>which call, on which input, produced which wrong output, at which step.</em> And now that
Step 4 scores every call, you don't even have to hunt: <strong>filter Tracing by low score</strong> and the
suspicious traces surface themselves — often a phrasing your part-3 dataset never covered, which is
exactly what you capture back next.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>pin a production failure to the exact step, input, and output</strong> — no more guessing
which call invented that refund policy.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--close-the-loop-production--better-evals">Step 6 — Close the loop: production → better evals<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#step-6--close-the-loop-production--better-evals" class="hash-link" aria-label="Direct link to Step 6 — Close the loop: production → better evals" title="Direct link to Step 6 — Close the loop: production → better evals" translate="no">​</a></h2>
<p>A production failure is a <strong>missing test case</strong> — and Langfuse makes capturing it one click. Create
the dataset once (<strong>Datasets → + New dataset</strong>, e.g. <code>production-failures</code>), then on any trace click
<strong>+ Add to datasets</strong> and pick it. The trace's input and output are prefilled as a new item:</p>
<p><span class="zoomImage__wrap"><img alt="Adding a trace back into the eval dataset" src="https://development-wec.wiline.com/docs/assets/images/langfuse-dataset-loop-9e09a4627451a945e4df5245e6018fe7.png" width="2522" height="1430" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> "Add to datasets" turns a production trace into a new test case — input and expected
output prefilled — feeding it straight back into your part-3 dataset.</p>
<p>To automate it, pull the traces you've bookmarked or added to a dataset via the Langfuse SDK, write
<code>{ticket, category, priority}</code> rows into <code>tickets_dataset.jsonl</code>, and re-run the part-1/part-3
Promptfoo eval — now it covers the real-world case.</p>
<p>Now your CI eval is stronger <em>because</em> production found a hole in it. That's the flywheel:
<strong>offline eval → ship → observe &amp; score real traffic → capture failures → offline eval gets
stronger.</strong></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You've closed the loop: <strong>production failures now make your evals stronger</strong> — the flywheel that
keeps an AI feature good after it ships.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-you-can-now-do">What you can now do<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#what-you-can-now-do" class="hash-link" aria-label="Direct link to What you can now do" title="Direct link to What you can now do" translate="no">​</a></h2>
<ul>
<li class=""><strong>See inside</strong> every WEC call in production (input/output/latency/cost).</li>
<li class=""><strong>Score real traffic automatically</strong> with an LLM judge — and debug the evaluator itself when it
goes quiet.</li>
<li class=""><strong>Debug a specific failure</strong> down to the exact step, not just an aggregate metric.</li>
<li class=""><strong>Turn production failures into test cases</strong>, so your evals keep improving.</li>
</ul>
<p>That's the "keep it good after shipping" rung. Offline eval (parts 1–3) proves it works before
launch; this proves — and keeps — it working after.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="address-already-in-use-on-docker-compose-up"><a id="ts-ports"></a>"address already in use" on <code>docker compose up</code><a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#address-already-in-use-on-docker-compose-up" class="hash-link" aria-label="Direct link to address-already-in-use-on-docker-compose-up" title="Direct link to address-already-in-use-on-docker-compose-up" translate="no">​</a></h3>
<p>Another service holds port 3000 (web) or 5432 (Postgres). The databases don't need host ports —
in <code>docker-compose.yml</code>, remap <code>langfuse-web</code> to a free port (<code>3001:3000</code>) and remove the Postgres
host mapping. Compose <em>merges</em> <code>ports</code> lists, so an override file with <code>ports: []</code> won't remove
them; edit the compose directly.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-web-container-wont-start">The web container won't start<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#the-web-container-wont-start" class="hash-link" aria-label="Direct link to The web container won't start" title="Direct link to The web container won't start" translate="no">​</a></h3>
<p>Almost always <code>ENCRYPTION_KEY</code> — it must be <code>openssl rand -hex 32</code> (64 hex chars), not base64.
Check <code>docker compose logs langfuse-web</code>.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cost-shows-0">Cost shows <code>$0</code><a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#cost-shows-0" class="hash-link" aria-label="Direct link to cost-shows-0" title="Direct link to cost-shows-0" translate="no">​</a></h3>
<p>Langfuse has no price for WEC's models by default. Add the model + per-token prices under
<strong>Settings → Models</strong> (Step 3).</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="no-scores-appear-on-new-traces">No scores appear on new traces<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#no-scores-appear-on-new-traces" class="hash-link" aria-label="Direct link to No scores appear on new traces" title="Direct link to No scores appear on new traces" translate="no">​</a></h3>
<p>Check <strong>Evaluators → your evaluator → Logs</strong> first — an error there tells you it's running and
failing, not idle. Then match the error:</p>
<ul>
<li class=""><strong>A 400 mentioning tool calls / <code>tool_choice</code></strong> — tool calling isn't enabled for that model on
the serving backend (on vLLM: the <code>--enable-auto-tool-choice</code> / <code>--tool-call-parser</code> flags).
Two ways out: point the judge's LLM Connection at a model where it works (<code>Qwen3.5:9B</code>), or ask
your platform team to enable the flags — that's what fixed <code>Qwen2.5-3B-Instruct</code> for us. Either
way, only the judge changes — your app keeps its model.</li>
<li class=""><strong><code>Request timed out after 120000ms</code></strong> — the judge's structured-output call is slower than
Langfuse's default timeout. Raise <code>LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS</code> (Step 4) and
confirm with <code>docker compose exec langfuse-worker env | grep TIMEOUT</code>. If plain <code>curl</code> is fast
but scoring still times out, don't chase the network — the structured-output path is what's
slow, and that's a backend fix.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="scores-take-2-minutes-each">Scores take ~2 minutes each<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#scores-take-2-minutes-each" class="hash-link" aria-label="Direct link to Scores take ~2 minutes each" title="Direct link to Scores take ~2 minutes each" translate="no">​</a></h3>
<p>That's not a hang — it's the reasoning model generating long hidden chain-of-thought on judge
prompts (highly variable: the same request can take 3s or 95s), plus structured-output overhead.
Raising the timeout makes scoring <em>reliable</em> but not <em>fast</em> — the real fix is platform-side
(reasoning budget caps, guided-decoding backend).</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Observe &amp; score production</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You can now build, prove, observe, and improve a single WEC feature. The last rung is the real
app pattern: <strong>RAG</strong> — retrieval + generation — with everything from parts 1–4 baked in
(schema-checked output, an eval dataset, and Langfuse tracing over the whole pipeline). That's
where it all comes together.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="further-reading">Further reading<a href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/#further-reading" class="hash-link" aria-label="Direct link to Further reading" title="Direct link to Further reading" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://langfuse.com/self-hosting" target="_blank" rel="noopener noreferrer" class="">Langfuse — self-hosting docs</a></li>
<li class=""><a href="https://langfuse.com/faq/all/best-phoenix-arize-alternatives" target="_blank" rel="noopener noreferrer" class="">Langfuse vs Arize Phoenix — trade-offs</a></li>
<li class=""><a href="https://posthog.com/blog/best-open-source-llm-observability-tools" target="_blank" rel="noopener noreferrer" class="">Open-source LLM observability tools compared (PostHog)</a></li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>observability</category>
            <category>langfuse</category>
            <category>evals</category>
            <category>inference</category>
            <category>production</category>
            <category>self-hosting</category>
        </item>
        <item>
            <title><![CDATA[Stop hand-writing test cases: generate an eval dataset with the WEC API]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/</guid>
            <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Two hand-typed test cases aren't an eval. Use the WEC Inference API to generate a labeled evaluation dataset — then validate and curate it, because generated labels aren't automatically correct. The result is real test data at scale; feed it to your harness and coverage surfaces the misclassifications and debatable labels two cases would hide. Every command and result is real.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-avatar-31b32c657d72dac0a7edbd77c24ec717.jpg" alt="Promptfoo"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="data:image/png;base64,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" alt="JSON"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">3</span><span class="skillTracker__skill" data-state="current">Real test data at scale</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Your eval passes 100% — of the <strong>two test cases you typed by hand.</strong> Real users won't phrase
things the way you did.</p>
<p>In <a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">part 1</a> and
<a class="" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/">part 2</a> we built a real eval harness —
assertions, schema validation, a model matrix. But two cases can't tell you if your app works;
they can only tell you it didn't crash on two inputs.</p>
<p>This guide fixes that. The skill is <strong>producing a dataset you can actually trust</strong>: we use the
<strong>WEC Inference API</strong> to <strong>generate labeled tickets</strong>, then <strong>validate and curate them</strong> — because
generated labels are <em>not</em> automatically correct, and treating them as gospel just moves the bug.
The result is real test data at scale. When you finally run it through the part-2 harness, coverage
surfaces the failures — genuine misclassifications <em>and</em> debatable labels — that two hand-picked
cases hide.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-it-fits-together">How it fits together<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#how-it-fits-together" class="hash-link" aria-label="Direct link to How it fits together" title="Direct link to How it fits together" translate="no">​</a></h2>
<p>Each step feeds the next, ending in a dataset that plugs straight into the part-2 eval:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">
<p>A <strong>WEC Inference API key</strong> — see <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>.</p>
</li>
<li class="">
<p><strong><code>jq</code></strong>, <strong><code>curl</code></strong>, and <strong>Node.js 22+</strong> (Promptfoo runs via <code>npx</code>).</p>
</li>
<li class="">
<p>Your key exported and sanitized (a pasted key often carries invisible characters):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'sk-your-key'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable builtin class-name" style="color:#36acaa">printf</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'%s'</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable assign-left variable environment constant" style="color:#36acaa">LC_ALL</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">C </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-cd</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'[:print:]'</span><span class="token variable" style="color:#36acaa">)</span><br></div></code></pre></div></div>
</li>
</ul>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>If a call returns a <code>401</code> / "invalid API key", your key is expired or revoked — generate a
fresh one in <strong>Inference → API Keys</strong>. See <a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#ts-401" class="">Troubleshooting</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--generate-one-labeled-batch">Step 1 — Generate one labeled batch<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-1--generate-one-labeled-batch" class="hash-link" aria-label="Direct link to Step 1 — Generate one labeled batch" title="Direct link to Step 1 — Generate one labeled batch" translate="no">​</a></h2>
<p>Ask the model for tickets <strong>with their answers</strong> — the labels are what make it a <em>dataset</em>, not
just inputs. We reuse the exact schema from part 2 (<code>category</code> + <code>priority</code> enums):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "model": "Qwen2.5-3B-Instruct",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "temperature": 0.7,</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "messages": [{"role":"user","content":"Generate 8 diverse, realistic customer support tickets for a cloud hosting company. Return ONLY JSONL (one JSON object per line), each with keys: ticket (string), category (one of: billing, technical, account, other), priority (one of: low, medium, high). No markdown, no fences."}]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  }'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{"ticket":"Our server went down unexpectedly and we're not sure what caused it.","category":"technical","priority":"high"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">{"ticket":"We need to upgrade our plan but the system is showing an error message when trying to change plans.","category":"account","priority":"medium"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">{"ticket":"We're having trouble accessing our database and can't seem to log in.","category":"technical","priority":"high"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">... 5 more lines ...</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Step 1 — one call returns clean, labeled JSONL" src="https://development-wec.wiline.com/docs/assets/images/dataset-gen-batch-adc9c63dcd4af4c2991e7581ea5908ec.png" width="2524" height="468" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> A single generation: 8 diverse tickets, each already carrying its <code>category</code> and <code>priority</code> label.</p>
<p>Clean JSONL, valid labels, real diversity — and it came back in seconds. We deliberately picked a
<strong>small, non-reasoning model</strong> (<code>Qwen2.5-3B-Instruct</code>) for generation. Here's why that matters.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Why not a reasoning model for bulk generation?</div><div class="admonitionContent_BuS1"><p>Reasoning models return their chain-of-thought in a separate <strong><code>reasoning_content</code></strong> field — tokens
you pay for but never use. Ask a reasoning model like <code>Qwen3.5:9B</code> for a few tickets and compare the
hidden reasoning against the actual answer:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"Qwen3.5:9B","temperature":0.7,"messages":[{"role":"user","content":"Generate 3 realistic customer support tickets for a cloud hosting company. Return ONLY JSONL, keys: ticket, category, priority. No markdown, no fences."}]}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token string" style="color:#e3116c">'{model, completion_tokens: .usage.completion_tokens, reasoning_chars: (.choices[0].message.reasoning_content|length), answer_chars: (.choices[0].message.content|length)}'</span><br></div></code></pre></div></div><div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"model"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Qwen3.5:9B"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"completion_tokens"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2000</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"reasoning_chars"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">7116</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token property" style="color:#36acaa">"answer_chars"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">412</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div><p><span class="zoomImage__wrap"><img alt="A reasoning model&amp;#39;s output — hidden reasoning dwarfs the answer" src="https://development-wec.wiline.com/docs/assets/images/dataset-gen-usage-5ee23a331ab19ce10dfddd509162743d.png" width="2536" height="370" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1b.</strong> For just 3 tickets, <code>Qwen3.5:9B</code> wrote <strong>7,116 characters of hidden reasoning to
produce 412 of answer</strong> — you pay for all ~2,000 completion tokens. (Counts vary per call; the
lopsidedness doesn't.) That's why we generate with the small, non-reasoning <code>Qwen2.5-3B</code> — and it's
the same reason those reasoning models stumble on structured output in Step 5's matrix.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--validate-before-you-trust-it">Step 2 — Validate before you trust it<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-2--validate-before-you-trust-it" class="hash-link" aria-label="Direct link to Step 2 — Validate before you trust it" title="Direct link to Step 2 — Validate before you trust it" translate="no">​</a></h2>
<p>Never treat generated data as ground truth without checking it. Save a batch and validate three
things: every line parses, every label is in-enum, and the classes are reasonably balanced.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"Qwen2.5-3B-Instruct","temperature":0.7,"messages":[{"role":"user","content":"Generate 8 diverse, realistic customer support tickets for a cloud hosting company. Return ONLY JSONL (one JSON object per line), each with keys: ticket (string), category (one of: billing, technical, account, other), priority (one of: low, medium, high). No markdown, no fences."}]}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> tickets.jsonl</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 1) do all lines parse as JSON?</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-e</span><span class="token plain"> </span><span class="token builtin class-name">.</span><span class="token plain"> tickets.jsonl </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> /dev/null </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"all lines valid JSON"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"INVALID JSON present"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 2) any out-of-enum labels? (no rows printed = all valid)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'select((.category|IN("billing","technical","account","other")|not) or (.priority|IN("low","medium","high")|not))'</span><span class="token plain"> tickets.jsonl</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 3) class distribution</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-rs</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'group_by(.category)[] | "\(.[0].category): \(length)"'</span><span class="token plain"> tickets.jsonl</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">all lines valid JSON</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">account: 2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">billing: 2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">other: 1</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">technical: 3</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Step 2 — validating the batch: valid JSON, in-enum labels, class distribution" src="https://development-wec.wiline.com/docs/assets/images/dataset-validate-9b1201646866c2062c5e9aa3d093cfd2.png" width="2534" height="434" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Validation output — every line parses, no out-of-enum labels, and a reasonable class spread.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>jq gotcha</div><div class="admonitionContent_BuS1"><p>The intuitive <code>["billing",...] | index(.category)</code> <strong>fails</strong> with <em>"Cannot index array with
string"</em> — inside the pipe, <code>.</code> is the array, so <code>.category</code> tries to index it. Use
<strong><code>.category | IN("billing", ...)</code></strong> instead. See <a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#ts-jq" class="">Troubleshooting</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--scale-up-and-dedupe">Step 3 — Scale up and dedupe<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-3--scale-up-and-dedupe" class="hash-link" aria-label="Direct link to Step 3 — Scale up and dedupe" title="Direct link to Step 3 — Scale up and dedupe" translate="no">​</a></h2>
<p>One batch of 8 isn't enough. Loop the call a few times (higher temperature for variety across
batches), concatenate, then dedupe by ticket text:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:#36acaa">i</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">in</span><span class="token plain"> </span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable function" style="color:#d73a49">seq</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">1</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable number" style="color:#36acaa">5</span><span class="token variable" style="color:#36acaa">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/chat/completions </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Content-Type: application/json"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'{"model":"Qwen2.5-3B-Instruct","temperature":0.9,"messages":[{"role":"user","content":"Generate 8 diverse, realistic customer support tickets for a cloud hosting company. Return ONLY JSONL (one JSON object per line), each with keys: ticket (string), category (one of: billing, technical, account, other), priority (one of: low, medium, high). No markdown, no fences."}]}'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.choices[0].message.content // empty'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"batch </span><span class="token string variable" style="color:#36acaa">$i</span><span class="token string" style="color:#e3116c"> done"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;2</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">done</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> tickets_raw.jsonl</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># dedupe by ticket text</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-sc</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'unique_by(.ticket)[]'</span><span class="token plain"> tickets_raw.jsonl </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> tickets_dataset.jsonl</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"raw: </span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable function" style="color:#d73a49">wc</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable parameter variable" style="color:#36acaa">-l</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable operator" style="color:#393A34">&lt;</span><span class="token string variable" style="color:#36acaa"> tickets_raw.jsonl</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">  →  deduped: </span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable function" style="color:#d73a49">wc</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable parameter variable" style="color:#36acaa">-l</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable operator" style="color:#393A34">&lt;</span><span class="token string variable" style="color:#36acaa"> tickets_dataset.jsonl</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">raw: 40  →  deduped: 40</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Step 3 — five batches concatenated and deduped to 40 unique rows" src="https://development-wec.wiline.com/docs/assets/images/dataset-scale-dedupe-74ac55864b3da488aa334816e11ede4a.png" width="2524" height="636" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> Scaling up: 5 batches → 40 rows after exact-duplicate removal.</p>
<p>Zero exact duplicates across 5 batches — a good sign the model isn't just repeating itself.</p>
<div class="theme-admonition theme-admonition-caution admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>caution</div><div class="admonitionContent_BuS1"><p><code>unique_by(.ticket)</code> only catches <strong>identical</strong> text. Two differently-worded "server is down"
tickets are <em>semantic</em> near-duplicates and will slip through — catching those needs
embedding-based clustering, a more advanced step. For a starter dataset, exact dedup is fine.</p></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Guard against silent failures</div><div class="admonitionContent_BuS1"><p>Note the <strong><code>// empty</code></strong> in the <code>jq</code> filter. If a call fails (revoked key, model timeout), the
response has no content and <code>.choices[0].message.content</code> is <code>null</code> — a plain <code>jq -r</code> would then
write the literal word <code>null</code> into your dataset, silently poisoning it. <code>// empty</code> drops those,
so a failed call adds <strong>nothing</strong> instead of a junk row. Always check the row count afterward.
See <a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#ts-null" class="">Troubleshooting</a>.</p></div></div>
<p>Validate the full 40 the same way as Step 2 (<code>jq -e .</code>, the enum check, and both distributions).
Ours came out balanced: technical 15 · account 10 · billing 10 · other 5; priority high 13 ·
medium 14 · low 13.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You now have a <strong>repeatable pipeline for real test data</strong> — 40 validated, deduped, labeled rows
where you used to have two.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--convert-to-a-promptfoo-dataset">Step 4 — Convert to a Promptfoo dataset<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-4--convert-to-a-promptfoo-dataset" class="hash-link" aria-label="Direct link to Step 4 — Convert to a Promptfoo dataset" title="Direct link to Step 4 — Convert to a Promptfoo dataset" translate="no">​</a></h2>
<p>Promptfoo reads test cases from a CSV: each column becomes a variable, and the special
<strong><code>__expected</code></strong> column holds a per-row assertion. We map <code>ticket</code> → the prompt var and
<code>__expected</code> → <code>icontains:&lt;the correct category&gt;</code>. <code>jq @csv</code> handles the commas and quotes
inside ticket text:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'ticket,__expected'</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'[.ticket, ("icontains:" + .category)] | @csv'</span><span class="token plain"> tickets_dataset.jsonl</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> tests.csv</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">wc</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-l</span><span class="token plain"> tests.csv          </span><span class="token comment" style="color:#999988;font-style:italic"># 41 = header + 40 rows</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--put-the-dataset-to-work">Step 5 — Put the dataset to work<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-5--put-the-dataset-to-work" class="hash-link" aria-label="Direct link to Step 5 — Put the dataset to work" title="Direct link to Step 5 — Put the dataset to work" translate="no">​</a></h2>
<p>The dataset is the deliverable — everything from here is what it <em>unlocks</em>. First payoff: a model
comparison you can actually believe. On two hand-typed cases a ranking is noise; on 40 real,
labeled rows it's a measurement. Add each WEC model as a provider and run the whole set:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "Ticket classifier — model matrix on the synthetic 40-row dataset"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen3.5-122B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  options:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    transform: |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      const m = output.match(/\{[\s\S]*?\}/);</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      return m ? m[0] : output;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests: file://tests.csv</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p>The result is the opposite of what "bigger is better" would predict:</p>
<table><thead><tr><th>Model</th><th>Accuracy</th><th>Completion tokens / ticket</th></tr></thead><tbody><tr><td><strong><code>Qwen2.5-3B-Instruct</code></strong> (non-reasoning)</td><td><strong>85%</strong> (34/40)</td><td>~30</td></tr><tr><td><code>Qwen3.5:9B</code> (reasoning)</td><td>47.5% (19/40)</td><td>~965</td></tr><tr><td><code>Qwen3.5-122B</code> (reasoning)</td><td>15% (6/40)</td><td>~1,013</td></tr></tbody></table>
<p><span class="zoomImage__wrap"><img alt="Step 5 — the model matrix over the 40-row dataset" src="https://development-wec.wiline.com/docs/assets/images/dataset-eval-matrix-9ffe20345f2040803fce117f6803a6ff.png" width="2406" height="1414" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Same dataset, three models — the small non-reasoning model wins by a mile.</p>
<p><strong>The small, non-reasoning model wins decisively — and the bigger the reasoning model, the <em>worse</em>
it does.</strong> The reasoning models aren't dumber at <em>classifying</em>; they <strong>bury the JSON in their
chain-of-thought</strong>. <code>Qwen3.5:9B</code> leaks <code>Thinking: …</code> into the output; <code>Qwen3.5-122B</code> often returns
an <strong>empty answer entirely</strong> — all ~1,000 tokens spent reasoning, nothing parseable left. They also
burn <strong>~30× more tokens per ticket</strong>, which is why the full matrix took <strong>12m 31s</strong>, almost all of
it spent waiting on the two big models. <strong>For structured output, thinking out loud is a liability,
not an advantage</strong> — a counterintuitive finding you'd never get from a leaderboard.</p>
<p>But notice what actually made that finding possible: <strong>the dataset.</strong> On two hand-typed cases those
percentages would be coin-flips; 40 labeled rows are what turn "which model?" from a guess into a
measurement. The comparison is the <em>reward</em> for building the data — not a substitute for it.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>pick a model with evidence instead of vibes</strong> — something no leaderboard can do for
you, because it doesn't know your task.</p></div></div>
<p>(The <code>transform</code> grabs the first <code>{…}</code> block with a <strong>non-greedy</strong> <code>*?</code> match — cheap insurance for
models that wrap JSON in prose, exactly like <a class="" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/">part 2</a>.)</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>We score <code>category</code> here — <code>priority</code> is your next assertion</div><div class="admonitionContent_BuS1"><p>The <code>__expected</code> column grades only the predicted <strong>category</strong> against the label. The dataset also
carries a <code>priority</code> label we don't score yet. To grade both fields at once, add a <code>javascript</code>
assertion that parses the JSON output and compares <code>priority</code> too — the exact same pattern, one more
check. Scoring more of what you generated is the cheapest way to make an eval stricter.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--dig-into-the-winners-failures">Step 6 — Dig into the winner's failures<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#step-6--dig-into-the-winners-failures" class="hash-link" aria-label="Direct link to Step 6 — Dig into the winner's failures" title="Direct link to Step 6 — Dig into the winner's failures" translate="no">​</a></h2>
<p>Take the winner — <code>Qwen2.5-3B</code> at <strong>85% (34/40)</strong> — and pull its 6 failing tickets, comparing each
to its label. (<code>__expected</code> isn't stored as a variable — Promptfoo consumes it as the assertion —
so read the ground truth from the source dataset.) Because the data is freshly generated, your exact
failures will differ, but they consistently split into two recognizable kinds — and <strong>neither is
just "the model is dumb". Representative examples:</strong></p>
<p><span class="zoomImage__wrap"><img alt="The Promptfoo report — per-row pass/fail across the 40-case dataset" src="https://development-wec.wiline.com/docs/assets/images/dataset-report-541b1a9921d846d5338c1b1547c49bd5.png" width="2526" height="1434" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> The web report makes it easy to scan which rows failed and open each one.</p>
<table><thead><tr><th>Ticket</th><th>Label</th><th>Why it's tricky</th></tr></thead><tbody><tr><td>"educational discounts for startups…"</td><td><code>other</code></td><td><strong>Debatable</strong> — it's about pricing, so <code>billing</code> is defensible</td></tr><tr><td>"grant a junior dev IAM access to S3…"</td><td><code>account</code></td><td><strong>Debatable</strong> — access control vs infrastructure</td></tr><tr><td>"feedback on the API documentation…"</td><td><code>other</code></td><td><strong>Debatable</strong> — feedback vs <code>technical</code></td></tr><tr><td>"update the card <strong>and</strong> add a user…"</td><td><code>account</code></td><td><strong>Multi-intent</strong> — genuinely two categories at once</td></tr></tbody></table>
<p>The rest are ordinary misclassifications. The real, day-to-day lessons:</p>
<ul>
<li class=""><strong>Your synthetic labels are not gospel.</strong> A chunk of "failures" are label <em>disputes</em> — review and
fix them, or your eval measures the wrong thing.</li>
<li class=""><strong>Single-label classification breaks on multi-intent tickets.</strong> Real inputs aren't always one category.</li>
<li class=""><strong>An 85% headline hides both.</strong> You only learn <em>why</em> by reading the failing rows — which is the
entire reason to eval on a real dataset instead of two examples.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-built">What we built<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#what-we-built" class="hash-link" aria-label="Direct link to What we built" title="Direct link to What we built" translate="no">​</a></h2>
<ul>
<li class="">The core skill: a repeatable pipeline to <strong>generate, validate, dedupe, and version</strong> an eval
dataset from the WEC API — real test data instead of two hand-typed cases.</li>
<li class="">The discipline that makes it trustworthy: <strong>generated labels aren't gospel.</strong> You validate enums
and balance, and you <em>review the disputes</em> — a chunk of "failures" are your own labels being wrong.</li>
<li class="">Proof it matters: <strong>coverage surfaces what two cases hide</strong> — genuine misclassifications <em>and</em>
debatable, multi-intent tickets you'd never think to type by hand.</li>
<li class="">A bonus the data unlocks: a <strong>trustworthy model comparison</strong> — here a <strong>3B non-reasoning model
beat a 122B reasoning one</strong> for structured output, the opposite of "bigger is better."</li>
</ul>
<p>Commit <code>tickets_dataset.jsonl</code> and <code>tests.csv</code> alongside your eval config, and regenerate/grow
the dataset as your product changes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="401--invalid-api-key"><a id="ts-401"></a>401 / invalid API key<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#401--invalid-api-key" class="hash-link" aria-label="Direct link to 401--invalid-api-key" title="Direct link to 401--invalid-api-key" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">{ "error": { "message": "Authentication Error, invalid API key", "code": "401" } }</span><br></div></code></pre></div></div>
<p>The key is expired, revoked, or malformed. Generate a fresh one in <strong>Inference → API Keys</strong>,
re-export it, and re-sanitize (<code>tr -cd '[:print:]'</code>) in case the paste carried an invisible character.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="jq-cannot-index-array-with-string"><a id="ts-jq"></a>jq: "Cannot index array with string"<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#jq-cannot-index-array-with-string" class="hash-link" aria-label="Direct link to jq-cannot-index-array-with-string" title="Direct link to jq-cannot-index-array-with-string" translate="no">​</a></h3>
<p>This comes from <code>["billing", ...] | index(.category)</code> — inside the pipe, <code>.</code> is the array, so
<code>.category</code> indexes the array. Use the <code>IN()</code> idiom instead:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">jq </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'select((.category|IN("billing","technical","account","other")|not))'</span><span class="token plain"> tickets.jsonl</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The jq &amp;quot;Cannot index array with string&amp;quot; error" src="https://development-wec.wiline.com/docs/assets/images/dataset-ts-jq-index-ec85a2581780363d791db73ff2f03fd0.png" width="2016" height="1402" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> The <code>index(.field)</code> mistake — inside the pipe, jq tries to index the array with a string.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="your-dataset-has-rows-that-are-just-null"><a id="ts-null"></a>Your dataset has rows that are just <code>null</code><a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#your-dataset-has-rows-that-are-just-null" class="hash-link" aria-label="Direct link to your-dataset-has-rows-that-are-just-null" title="Direct link to your-dataset-has-rows-that-are-just-null" translate="no">​</a></h3>
<p>A <code>null</code> row means a generation call failed but the loop still wrote its (empty) result. Usual
causes: an expired/revoked API key, or the model timing out. Fixes: extract with
<code>.choices[0].message.content // empty</code> so a failed call writes <strong>nothing</strong> instead of <code>null</code>;
re-check <code>wc -l</code> after every generation run; and if calls hang, add a per-request timeout
(<code>curl -m 60</code>) and consider a lighter, faster model for bulk generation.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="__expected-is-null-in-the-results-json"><code>__expected</code> is <code>null</code> in the results JSON<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#__expected-is-null-in-the-results-json" class="hash-link" aria-label="Direct link to __expected-is-null-in-the-results-json" title="Direct link to __expected-is-null-in-the-results-json" translate="no">​</a></h3>
<p>That's expected — Promptfoo <strong>consumes</strong> the <code>__expected</code> CSV column as the row's assertion, so
it isn't kept as a variable. To inspect ground-truth labels for failing rows, read them from your
source dataset (<code>tickets_dataset.jsonl</code>) rather than the results file.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Real test data at scale</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You now have a real dataset — but it runs in CI, offline. The next step is watching your app in
<strong>production</strong>: self-hosting <strong>Langfuse</strong> to trace every call, track latency and cost, and run
evals (using <em>this dataset</em>) against live traffic. That's where offline testing becomes real
observability — and it's the next post in the series.</p>]]></content:encoded>
            <category>ai</category>
            <category>evals</category>
            <category>promptfoo</category>
            <category>inference</category>
            <category>datasets</category>
            <category>synthetic-data</category>
            <category>observability</category>
        </item>
        <item>
            <title><![CDATA[Trustworthy JSON: schema-validate your model's structured output]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/</guid>
            <pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[LLMs promise JSON and deliver markdown fences and reasoning. Build a Promptfoo eval that classifies support tickets into schema-validated JSON on the WEC Inference API — with transforms to recover messy output, a model-reliability matrix, and a CI gate. Every command and result is real.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-avatar-31b32c657d72dac0a7edbd77c24ec717.jpg" alt="Promptfoo"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="data:image/png;base64,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" alt="JSON"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Prove a model works</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">2</span><span class="skillTracker__skill" data-state="current">Trustworthy JSON</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>"Return only JSON" is one of the most common instructions in production LLM apps — and one
of the least reliable. Models wrap JSON in markdown fences, add a friendly sentence, or (if
they're reasoning models) narrate their entire thought process around it. Any of those
breaks a strict <code>JSON.parse</code>, and your pipeline falls over.</p>
<p>In this guide we build a <a href="https://www.promptfoo.dev/" target="_blank" rel="noopener noreferrer" class="">Promptfoo</a> eval that makes <strong>WEC
Inference API</strong> models classify support tickets into <strong>schema-validated JSON</strong>, then harden
it against real-world messiness — and use it to pick a model you can actually trust. This is
part 2 of the evals series (see <a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">part 1</a>
for first-run setup). Everything here was run live against <code>https://inference.wiline.com</code>.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-the-eval-works">How the eval works<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#how-the-eval-works" class="hash-link" aria-label="Direct link to How the eval works" title="Direct link to How the eval works" translate="no">​</a></h2>
<p>Every test runs through the same pipeline. The detail that matters most: the <strong><code>transform</code>
runs on the model's output <em>before</em> the assertion sees it</strong> — that's what lets us recover
messy JSON before validating it.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">
<p>A <strong>WEC Inference API key</strong> — see <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>.</p>
</li>
<li class="">
<p>The model names used here (<code>GLM-5.2</code>, <code>gemma4</code>, <code>Qwen2.5-3B-Instruct</code>, <code>qwen3.5:9B</code>) come
from the <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/models_hub/">Models Hub</a> — swap in whatever you've enabled.</p>
</li>
<li class="">
<p><strong>Node.js 22+</strong> and your key exported (Promptfoo runs via <code>npx</code>):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">nvm use </span><span class="token number" style="color:#36acaa">24</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'sk-your-key'</span><br></div></code></pre></div></div>
</li>
</ul>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>If your first run errors with <code>Cannot convert argument to a ByteString</code>, your pasted key has
an invisible character — see <a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#ts-invisible-key" class="">Troubleshooting</a> at the bottom.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--a-strict-schema-and-a-real-failure">Step 1 — A strict schema, and a real failure<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-1--a-strict-schema-and-a-real-failure" class="hash-link" aria-label="Direct link to Step 1 — A strict schema, and a real failure" title="Direct link to Step 1 — A strict schema, and a real failure" translate="no">​</a></h2>
<p>We'll do something closer to a real workload than "capital of France": classify a support
ticket into <code>category</code>, <code>priority</code>, and a short <code>summary</code>. The key upgrade over a basic
check is the <strong>JSON Schema</strong> — it enforces <code>enum</code>s, so a model that invents a category fails.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — structured output (JSON schema)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p>The result — <strong>50%</strong>, and the failure is instructive:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ticket                                    [zai-org/GLM-5.2]</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">I was charged twice for my instance…      [FAIL] ```json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                          { "category": "billing", "priority": "high", … }</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                          ```</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">My VM won't boot after the latest…        [PASS] { "category": "technical", … }</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ 1 passed (50%)   ✗ 1 failed (50%)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Step 1 — one ticket fails because the JSON is wrapped in markdown fences" src="https://development-wec.wiline.com/docs/assets/images/json-step1-schema-fail-dc2b9c12c0ee29b0086499d5c76f2fd6.png" width="964" height="756" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> Same model, same prompt, temperature 0 — one response is clean, the other is
wrapped in <code>```json</code> fences, so <code>is-json</code> can't parse it.</p>
<p>The <em>content</em> is correct; the <em>format</em> isn't. <code>is-json</code> validates both "is it parseable JSON"
and "does it match the schema" — and fenced output fails the first test.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--recover-fenced-output-with-a-transform">Step 2 — Recover fenced output with a <code>transform</code><a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-2--recover-fenced-output-with-a-transform" class="hash-link" aria-label="Direct link to step-2--recover-fenced-output-with-a-transform" title="Direct link to step-2--recover-fenced-output-with-a-transform" translate="no">​</a></h2>
<p>A <code>transform</code> runs on the model's output <strong>before</strong> assertions. Here we strip the fences.
(This block uses four backticks because the config itself contains triple backticks.)</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — fence-stripping transform"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  options:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    transform: |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      return output.replace(/```json\n?|\n?```/g, '').trim();</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p>Now both pass — <strong>2/2</strong>. The fences are stripped before <code>is-json</code> sees the output.</p>
<p><span class="zoomImage__wrap"><img alt="Step 2 — with the fence-stripping transform, both tickets pass" src="https://development-wec.wiline.com/docs/assets/images/json-step2-transform-pass-14cb388c6725d0aacffeee1bb320f553.png" width="966" height="735" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> The <code>transform</code> removes the code fences, so the previously-failing ticket parses.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span><code>transform</code> needs an explicit <code>return</code></div><div class="admonitionContent_BuS1"><p>A multi-line <code>transform</code> is a function body, not an expression — omit <code>return</code> and you get
<code>Transform function did not return a value</code>. See <a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#ts-transform-return" class="">Troubleshooting</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-cleaner-fix-json-mode">Step 3 — The cleaner fix: JSON mode<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-3--the-cleaner-fix-json-mode" class="hash-link" aria-label="Direct link to Step 3 — The cleaner fix: JSON mode" title="Direct link to Step 3 — The cleaner fix: JSON mode" translate="no">​</a></h2>
<p>Rather than clean up after the model, ask the API to only emit valid JSON via
<code>response_format</code>. No <code>transform</code> this time:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — JSON mode (response_format)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      response_format: { type: json_object }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p><strong>2/2 PASS</strong> with no transform — looks like JSON mode works.</p>
<p><span class="zoomImage__wrap"><img alt="Step 3 — JSON mode passes on GLM-5.2" src="https://development-wec.wiline.com/docs/assets/images/json-step3-jsonmode-6cabcc01d214aaa3b14b1a0842eeb720.png" width="962" height="718" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> With <code>response_format: json_object</code>, GLM-5.2 returns clean JSON, no transform needed.</p>
<p>But before trusting <code>response_format</code>, we need to test it across models — because a single
green result can be luck, not enforcement.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--reliability-matrix-across-wec-models">Step 4 — Reliability matrix across WEC models<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-4--reliability-matrix-across-wec-models" class="hash-link" aria-label="Direct link to Step 4 — Reliability matrix across WEC models" title="Direct link to Step 4 — Reliability matrix across WEC models" translate="no">​</a></h2>
<p>Run the JSON-mode config against all four chat models. This is the payoff — a
<strong>WEC-specific reliability comparison</strong> you can't get from a generic leaderboard.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — JSON-mode reliability across models"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0, response_format: { type: json_object } }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p><strong>4/8.</strong> GLM-5.2 and Qwen2.5-3B pass cleanly; <strong>gemma4 and qwen3.5:9B dump their reasoning</strong>
("Thinking Process…") instead of JSON — despite <code>response_format</code> being set.</p>
<p><span class="zoomImage__wrap"><img alt="Step 4 — with JSON mode, two models pass and two emit reasoning" src="https://development-wec.wiline.com/docs/assets/images/json-matrix-responseformat-d227b3a1ca8bffbf85f89ca2da11eb87.png" width="826" height="983" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> <code>response_format</code> set on all four — but the two reasoning-oriented models ignore it.</p>
<table><thead><tr><th>Model</th><th>Result</th><th>Tokens (2 calls)</th></tr></thead><tbody><tr><td><code>zai-org/GLM-5.2</code></td><td>✅✅</td><td>202</td></tr><tr><td><code>Qwen2.5-3B-Instruct</code></td><td>✅✅</td><td>243</td></tr><tr><td><code>gemma4</code></td><td>❌❌ (reasoning)</td><td>251</td></tr><tr><td><code>qwen3.5:9B</code></td><td>❌❌ (reasoning)</td><td>1,757</td></tr></tbody></table>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>measure which models actually honor JSON mode</strong> — instead of trusting the flag.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--verify-is-response_format-doing-anything">Step 5 — Verify: is <code>response_format</code> doing anything?<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-5--verify-is-response_format-doing-anything" class="hash-link" aria-label="Direct link to step-5--verify-is-response_format-doing-anything" title="Direct link to step-5--verify-is-response_format-doing-anything" translate="no">​</a></h2>
<p>Don't assume the reasoning was <em>caused</em> by JSON mode. Re-run the same matrix <strong>without</strong>
<code>response_format</code> (using the fence-strip transform instead) and compare:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — matrix without response_format"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  options:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    transform: |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      return output.replace(/```json\n?|\n?```/g, '').trim();</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p>Still <strong>4/8</strong> — the exact same models fail. So <code>response_format</code> <strong>changed nothing</strong>: on WEC
today it's effectively a no-op (the backend isn't enforcing JSON mode). The reasoning
behavior belongs to the models, not to the flag.</p>
<p><span class="zoomImage__wrap"><img alt="Step 5 — same 4/8 without response_format, proving it was a no-op" src="https://development-wec.wiline.com/docs/assets/images/json-matrix-transform-77179c366ad47c9eb5b00fe2b5d92381.png" width="824" height="983" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> Identical result without JSON mode — the flag wasn't doing anything.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p>Lesson worth its own callout: <strong>don't attribute a result to a change until you've tested the
change in isolation.</strong> One extra run turned a wrong conclusion into the right one.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--a-more-robust-transform-extract-the-json">Step 6 — A more robust transform: extract the JSON<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-6--a-more-robust-transform-extract-the-json" class="hash-link" aria-label="Direct link to Step 6 — A more robust transform: extract the JSON" title="Direct link to Step 6 — A more robust transform: extract the JSON" translate="no">​</a></h2>
<p>The reasoning models <em>do</em> emit JSON — it's just buried in prose. Instead of stripping fences,
<strong>extract the first <code>{ … }</code> block</strong> from anywhere in the output:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API — JSON extraction transform"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Classify this support ticket. Return ONLY JSON with keys:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "category" (one of: billing, technical, account, other),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "priority" (one of: low, medium, high),</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    "summary" (string, max 12 words).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    Ticket: {{ticket}}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  options:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    transform: |</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      const m = output.match(/\{[\s\S]*\}/);</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      return m ? m[0] : output;</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [category, priority, summary]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          category: { type: string, enum: [billing, technical, account, other] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          priority: { type: string, enum: [low, medium, high] }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          summary: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "I was charged twice for my instance this month." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: billing }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { ticket: "My VM won't boot after the latest snapshot restore." }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: technical }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p><strong>7/8</strong> — a big jump. gemma4 and Qwen2.5 fully recover; qwen3.5:9B recovers one of two. The
one remaining failure is telling: that response emitted JSON <strong>and then kept reasoning</strong> (with
more braces), so the greedy <code>{ … }</code> match over-captured and produced invalid JSON.</p>
<p><span class="zoomImage__wrap"><img alt="Step 6 — JSON extraction rescues most models (7/8)" src="https://development-wec.wiline.com/docs/assets/images/json-matrix-extraction-3b34ee4a32f6990e447d9590d51d8911.png" width="827" height="901" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> Extracting the JSON block recovers the chatty models — but it's a heuristic, not
a guarantee (note the one over-capture failure).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-7--see-it-in-the-browser">Step 7 — See it in the browser<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-7--see-it-in-the-browser" class="hash-link" aria-label="Direct link to Step 7 — See it in the browser" title="Direct link to Step 7 — See it in the browser" translate="no">​</a></h2>
<p>The terminal table is fine for a quick read, but the web report is where a matrix like this
comes alive:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest view</span><br></div></code></pre></div></div>
<p>The dashboard at the top gives you the model-reliability picture at a glance — the <strong>Pass
Rate (%)</strong> bars are the ones to watch here: three models tall, <code>qwen3.5:9B</code> sitting at 50%.
Below, each provider is a column you can expand to the <strong>full</strong> response (that's how we pulled
the over-capture failure in <a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#ts-reasoning-dump" class="">Figure 10</a> — the terminal truncates it).</p>
<p><span class="zoomImage__wrap"><img alt="The Promptfoo report: Pass Rate bars per model and expandable per-case output" src="https://development-wec.wiline.com/docs/assets/images/json-view-report-3739e5abd8419ffc8ece8271ff559851.png" width="1896" height="1034" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The report's dashboard — the Pass Rate bars turn the model matrix into a
one-glance ranking, and any cell expands to the full output.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p>The report's other charts (the prompt-vs-prompt scatter) shine when you're <strong>A/B-testing
prompts</strong>, not models — a good subject for a later post. For a single-prompt, multi-model run
like this one, the Pass Rate bars are the chart that matters.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-8--gate-it-in-ci">Step 8 — Gate it in CI<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#step-8--gate-it-in-ci" class="hash-link" aria-label="Direct link to Step 8 — Gate it in CI" title="Direct link to Step 8 — Gate it in CI" translate="no">​</a></h2>
<p>Promptfoo exits non-zero when tests fail, so a one-liner is a gate (put it in a script or
<code>Makefile</code>, never your interactive shell):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"✅ passed"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"❌ eval failed — blocking"</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">exit</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The gate blocking on a failed eval" src="https://development-wec.wiline.com/docs/assets/images/json-ci-gate-71912b506157c96cfd7daa8e5c81f300.png" width="948" height="977" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> A real failure blocks: 7/8 passed, but <code>qwen3.5:9B</code>'s over-capture (from Step 6)
trips the gate — the non-zero exit code is the signal CI uses to block a PR.</p>
<p>On GitLab:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">eval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> node</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">22</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">script</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> npx </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">y promptfoo@latest eval </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">c promptfooconfig.yaml</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic"># WEC_API_KEY set as a masked CI/CD variable — never committed.</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p><strong>Schema-validated JSON is now a CI gate</strong> — a model or prompt change that breaks parsing
never ships.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-learned">What we learned<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#what-we-learned" class="hash-link" aria-label="Direct link to What we learned" title="Direct link to What we learned" translate="no">​</a></h2>
<ul>
<li class=""><strong>Model choice dominates.</strong> <code>GLM-5.2</code> and <code>Qwen2.5-3B</code> reliably produce schema-valid JSON
and are ~8× cheaper than the reasoning models. For structured output on WEC, start there.</li>
<li class=""><strong>Reasoning models</strong> (<code>qwen3.5:9B</code>, and <code>gemma4</code> on the day we tested) narrate their thinking
and cost far more — poor fits for strict JSON.</li>
<li class=""><strong>A <code>transform</code> is your safety net</strong> — fence-stripping recovers the common case; JSON
extraction recovers most chatty output — but extraction is a heuristic that can over-capture.</li>
<li class=""><strong><code>response_format</code> is a no-op on WEC today</strong> — verified by isolating it. Don't rely on it;
validate instead.</li>
<li class=""><strong>The eval is the point.</strong> Every one of these surprises was caught automatically, before it
reached production.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting">Troubleshooting<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#troubleshooting" class="hash-link" aria-label="Direct link to Troubleshooting" title="Direct link to Troubleshooting" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="transform-function-did-not-return-a-value"><a id="ts-transform-return"></a>"Transform function did not return a value"<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#transform-function-did-not-return-a-value" class="hash-link" aria-label="Direct link to transform-function-did-not-return-a-value" title="Direct link to transform-function-did-not-return-a-value" translate="no">​</a></h3>
<p>A multi-line <code>transform</code> is compiled as a <strong>function body</strong>, so it needs an explicit
<code>return</code>. <code>output.replace(...)</code> on its own returns nothing.</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">options</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">transform</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">|</span><span class="token scalar string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token scalar string" style="color:#e3116c">    return output.replace(/.../g, '').trim();   # note the `return`</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The transform-return error" src="https://development-wec.wiline.com/docs/assets/images/json-ts-transform-return-764896c26bc05d65d37eac7808ee06fb.png" width="827" height="894" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> Without <code>return</code>, Promptfoo errors instead of transforming.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-model-returns-reasoning-around-the-json"><a id="ts-reasoning-dump"></a>A model returns reasoning around the JSON<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#a-model-returns-reasoning-around-the-json" class="hash-link" aria-label="Direct link to a-model-returns-reasoning-around-the-json" title="Direct link to a-model-returns-reasoning-around-the-json" translate="no">​</a></h3>
<p>If <code>is-json</code> fails with <strong>"Expected output to be valid JSON"</strong> and the response contains a
"Thinking Process…" / "Final Review…" narration, you're using a reasoning model. Sometimes it
emits <em>only</em> reasoning; sometimes — as below — it emits valid JSON and then <strong>keeps talking</strong>,
so even the Step 6 extraction over-captures and the parse fails. Options: switch to
<code>GLM-5.2</code> / <code>Qwen2.5-3B</code>, or accept that the extraction transform is best-effort, not a guarantee.</p>
<p><span class="zoomImage__wrap"><img alt="The failing case expanded in the report — JSON followed by trailing reasoning" src="https://development-wec.wiline.com/docs/assets/images/json-ts-reasoning-dump-dbfebf9e8d4f5f89e6b2e14b8e0edf50.png" width="561" height="859" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 10.</strong> The failing case expanded in the report: <code>qwen3.5:9B</code> returned valid JSON and
<em>then kept reasoning</em> ("7. Final Review…"), so the extraction over-captured and the assertion
reports "Expected output to be valid JSON."</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cannot-convert-argument-to-a-bytestring"><a id="ts-invisible-key"></a>"Cannot convert argument to a ByteString"<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#cannot-convert-argument-to-a-bytestring" class="hash-link" aria-label="Direct link to cannot-convert-argument-to-a-bytestring" title="Direct link to cannot-convert-argument-to-a-bytestring" translate="no">​</a></h3>
<p>Your pasted API key contains an invisible Unicode character. Strip it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable builtin class-name" style="color:#36acaa">printf</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'%s'</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable assign-left variable environment constant" style="color:#36acaa">LC_ALL</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">C </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-cd</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'[:print:]'</span><span class="token variable" style="color:#36acaa">)</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The ByteString error from an invisible key character" src="https://development-wec.wiline.com/docs/assets/images/json-ts-bytestring-0bb4979ce94f0775f1fe314315b38e01.png" width="953" height="988" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 11.</strong> A stray character in the key trips the HTTP header before any request is sent.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Trustworthy JSON</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<ul>
<li class=""><strong>DeepEval regression suites</strong> — turn these checks into a versioned, CI-gated regression run.</li>
<li class=""><strong>Tracing</strong> — see <em>why</em> a response went wrong, not just that it failed.</li>
</ul>
<p>Have a structured-output case that still slips through? That's the next test to add.</p>]]></content:encoded>
            <category>ai</category>
            <category>evals</category>
            <category>promptfoo</category>
            <category>inference</category>
            <category>json</category>
            <category>structured-output</category>
            <category>observability</category>
        </item>
        <item>
            <title><![CDATA[Evaluate your models with Promptfoo on the WEC Inference API]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/</guid>
            <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Stop eyeballing LLM output. Build a real evaluation harness with Promptfoo pointed at the WEC Inference API — assertions, latency guardrails, JSON-schema checks, model-graded rubrics, an all-WEC model comparison, and a CI gate. Every command and result is real.]]></description>
            <content:encoded><![CDATA[
<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-avatar-31b32c657d72dac0a7edbd77c24ec717.jpg" alt="Promptfoo"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 8 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->8</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->8<!-- --> earned</span></div><div class="skillTracker__series">AI evals &amp; observability</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">Prove a model works</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Trustworthy JSON</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/synthetic-eval-datasets-wiline-inference/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Real test data at scale</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/observe-production-langfuse-wiline/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Observe &amp; score production</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/rag-docs-assistant-wiline-inference/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">RAG, end to end</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deepeval-regression-testing-wiline-inference/"><span class="skillTracker__dot" data-state="locked">6</span><span class="skillTracker__skill" data-state="locked">Catch regressions in CI</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/component-tracing-agent-tool-calls-wiline/"><span class="skillTracker__dot" data-state="locked">7</span><span class="skillTracker__skill" data-state="locked">Trace &amp; debug agent tool calls</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/web-search-wiline-inference/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Add live web search</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>You wouldn't ship code without tests — but most teams ship LLM features on "looks good
to me." <strong>Evaluations</strong> (evals) fix that: you define test cases and pass/fail criteria, then
measure your model objectively — catching regressions, comparing models, and gating
deploys.</p>
<p>In this guide you'll build a real eval harness with <a href="https://www.promptfoo.dev/" target="_blank" rel="noopener noreferrer" class="">Promptfoo</a>
pointed at the <strong>WEC Inference API</strong> — the same OpenAI-compatible endpoint you call from
your apps. Everything here was run live against <code>https://inference.wiline.com</code>; the outputs
are real.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">
<p>A <strong>WEC (WiLine Edge Cloud)</strong> account and an <strong>Inference API key</strong> — see
<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a>.</p>
</li>
<li class="">
<p><strong>Node.js 22+</strong>. Promptfoo treats Node 20 as end-of-life; with <code>nvm</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">nvm </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">24</span><span class="token plain">      </span><span class="token comment" style="color:#999988;font-style:italic"># 24 LTS preferred</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">nvm use </span><span class="token number" style="color:#36acaa">24</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain">             </span><span class="token comment" style="color:#999988;font-style:italic"># v24.x</span><br></div></code></pre></div></div>
</li>
</ul>
<p>We'll run Promptfoo with <code>npx</code> — nothing to install globally.</p>
<p><span class="zoomImage__wrap"><img alt="The WiLine portal API Keys page" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-api-key-a1aca6bfffa7335651ab59ec1c07d8f4.png" width="1642" height="866" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> Create a key under Inference → API Keys, then copy it.</p>
<p>Put your key in the shell:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'sk-your-key-here'</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>If your first eval errors with <code>Cannot convert argument to a ByteString</code>, see <a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#1-bytestring-error-on-first-eval" class="">Troubleshooting #1</a> below.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--see-which-models-you-have">Step 1 — See which models you have<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-1--see-which-models-you-have" class="hash-link" aria-label="Direct link to Step 1 — See which models you have" title="Direct link to Step 1 — See which models you have" translate="no">​</a></h2>
<p>The API is OpenAI-compatible, so the model list is a plain <code>GET /v1/models</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> https://inference.wiline.com/v1/models </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> jq </span><span class="token parameter variable" style="color:#36acaa">-r</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'.data[].id'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">whisper-large-v3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">whisper-medium</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">whisper-base</span><br></div></code></pre></div></div>
<p>The <code>whisper-*</code> models are speech-to-text; the rest are chat models. We'll evaluate the
chat models, starting with <code>zai-org/GLM-5.2</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--your-first-eval">Step 2 — Your first eval<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-2--your-first-eval" class="hash-link" aria-label="Direct link to Step 2 — Your first eval" title="Direct link to Step 2 — Your first eval" translate="no">​</a></h2>
<p>Create a config. <code>apiBaseUrl</code> points Promptfoo at WEC; <code>apiKeyEnvar</code> tells it which env
var holds your key:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - "Answer in a single word. What is the capital of {{country}}?"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: first eval, 2 cases both PASS" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-first-eval-42c0a9a515e6a1e5af7a6cf5c6a30118.png" width="954" height="501" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 4.</strong> Your first passing eval against the WEC Inference API.</p>
<p><code>icontains</code> is a <strong>deterministic</strong> assertion — a case-insensitive substring check. Cheap,
fast, and perfect for facts with a known answer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--the-visual-report">Step 3 — The visual report<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-3--the-visual-report" class="hash-link" aria-label="Direct link to Step 3 — The visual report" title="Direct link to Step 3 — The visual report" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest view</span><br></div></code></pre></div></div>
<p>This opens a local web UI with every prompt, output, and pass/fail — far easier to scan
than the terminal once you have more than a handful of cases.</p>
<p><span class="zoomImage__wrap"><img alt="The Promptfoo web report" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-view-report-b93aa9bcadc4c60e008df1bb5effcd3c.png" width="1904" height="1040" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 5.</strong> <code>promptfoo view</code> — the same results in the browser.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--add-a-second-model">Step 4 — Add a second model<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-4--add-a-second-model" class="hash-link" aria-label="Direct link to Step 4 — Add a second model" title="Direct link to Step 4 — Add a second model" translate="no">​</a></h2>
<p>Adding a provider is one extra block. Here we add <code>Qwen2.5-3B-Instruct</code> alongside GLM-5.2
and run the same two test cases against both:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — model comparison"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - "Answer in a single word. What is the capital of {{country}}?"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: 2-model comparison, 4 cases all PASS" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-two-model-3c6c51ea8839c03dd51d270c78648ec9.png" width="956" height="590" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 6.</strong> Both models pass. GLM-5.2's results are served from cache (0 new requests); only Qwen2.5 makes live calls.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--grade-quality-not-substrings-llm-rubric">Step 5 — Grade quality, not substrings (LLM rubric)<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-5--grade-quality-not-substrings-llm-rubric" class="hash-link" aria-label="Direct link to Step 5 — Grade quality, not substrings (LLM rubric)" title="Direct link to Step 5 — Grade quality, not substrings (LLM rubric)" translate="no">​</a></h2>
<p>Substring checks can't judge an open-ended answer. A <strong>model-graded</strong> <code>llm-rubric</code> assertion
uses a model to score the output against criteria — and the grader can run on WEC too, so
you need no external provider:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — model-graded (LLM rubric)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  options:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    provider:                       # the grader model</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - "Explain what a {{topic}} is in two sentences, for a beginner."</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { topic: VPC }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - type: llm-rubric</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        value: "Describes a VPC as a private, isolated virtual network in the cloud, and is understandable to a beginner."</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: LLM rubric eval, 1 PASS, Grading tokens line visible" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-llm-rubric-f969ad98a43427eacf9f83f13f2e6dde.png" width="957" height="556" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 7.</strong> The run shows two token lines — <code>Eval</code> (75) and <code>Grading</code> (350) — because the rubric check is itself a model call.</p>
<p>Model-graded checks are powerful, but they aren't free.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--guardrails-latency">Step 6 — Guardrails: latency<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-6--guardrails-latency" class="hash-link" aria-label="Direct link to Step 6 — Guardrails: latency" title="Direct link to Step 6 — Guardrails: latency" translate="no">​</a></h2>
<p>A correct answer that takes 30 seconds is still a production problem. Add a <strong>latency</strong>
guardrail to <code>defaultTest</code> (it applies to every case). Always run with <code>--no-cache</code> — cached
responses report ~0 ms and would pass the gate for free:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — latency guardrail"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: latency</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      threshold: 5000        # fail anything slower than 5 s</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - "Answer in a single word. What is the capital of {{country}}?"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p>This <strong>failed</strong> — the answers were correct (<code>Paris</code>, <code>Tokyo</code>) but both calls took ~33 s,
tripping the 5 s gate:</p>
<p><span class="zoomImage__wrap"><img alt="Terminal: latency 5000ms threshold, 2 FAIL, Duration: 33s" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-latency-fail-ab40e8dc4386c6acd496942ee3a490aa.png" width="953" height="518" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 8.</strong> The guardrail fires: correct answers, wrong latency. Duration: 33s.</p>
<p>Bump the threshold to something realistic and re-run:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/threshold: 5000/threshold: 60000/'</span><span class="token plain"> promptfooconfig.yaml</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: latency 60000ms threshold, 2 PASS, Duration: 2s" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-latency-pass-f3fa1a1c9cb0a11a87328b2a15ef1f31.png" width="957" height="538" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 9.</strong> With a 60 s threshold the same calls pass easily — Duration: 2s.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Inference latency varies — calibrate before gating</div><div class="admonitionContent_BuS1"><p>Back-to-back calls to the same model ranged from ~2 s to ~33 s on the same day. Run a
few <code>--no-cache</code> passes to get a realistic baseline before locking in your threshold.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-7--guardrails-latency--token-cap">Step 7 — Guardrails: latency + token cap<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-7--guardrails-latency--token-cap" class="hash-link" aria-label="Direct link to Step 7 — Guardrails: latency + token cap" title="Direct link to Step 7 — Guardrails: latency + token cap" translate="no">​</a></h2>
<p>The <code>cost</code> assertion doesn't work against WEC (see <a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#2-cost-assertion-errors-immediately" class="">Troubleshooting #2</a>). Use latency + <code>max_tokens</code> together as your production guardrail instead:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — guardrails (latency + token cap)"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      max_tokens: 16</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: latency</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      threshold: 30000</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - "Answer in a single word. What is the capital of {{country}}?"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml --no-cache</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: latency 30000ms + max_tokens:16, 2 PASS" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-latency-token-cap-968bf5b53dd3edf6d77129f011e4a66f.png" width="955" height="522" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 11.</strong> With a 30 s threshold and a 16-token cap, both cases pass.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-8--validate-structured-output-json-schema">Step 8 — Validate structured output (JSON schema)<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-8--validate-structured-output-json-schema" class="hash-link" aria-label="Direct link to Step 8 — Validate structured output (JSON schema)" title="Direct link to Step 8 — Validate structured output (JSON schema)" translate="no">​</a></h2>
<p>Real apps want JSON, not prose. <code>is-json</code> checks the output is valid JSON <strong>and</strong> matches a
schema in one assertion:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — JSON schema validation"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiBaseUrl: https://inference.wiline.com/v1</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      apiKeyEnvar: WEC_API_KEY</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      temperature: 0</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - 'Return ONLY a JSON object (no markdown, no prose) with keys "capital" (string) and "population_millions" (number) for the country {{country}}.'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [capital, population_millions]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          capital: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          population_millions: { type: number }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: JSON schema eval, France and Japan both PASS with clean JSON output" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-json-schema-82dcc59881029f9bc0134dcf5a5c5ea2.png" width="955" height="516" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 12.</strong> GLM-5.2 returns compact, schema-valid JSON on the first try.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-9--the-payoff-compare-every-wec-model">Step 9 — The payoff: compare every WEC model<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-9--the-payoff-compare-every-wec-model" class="hash-link" aria-label="Direct link to Step 9 — The payoff: compare every WEC model" title="Direct link to Step 9 — The payoff: compare every WEC model" translate="no">​</a></h2>
<p>Here's what you can't get anywhere else — the same strict-JSON test across <strong>all</strong> the chat
models on your account:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">description: "WEC Inference API eval — model matrix"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">providers:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:zai-org/GLM-5.2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:gemma4</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:Qwen2.5-3B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - id: openai:chat:qwen3.5:9B</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">prompts:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - 'Return ONLY a JSON object (no markdown, no prose) with keys "capital" (string) and "population_millions" (number) for the country {{country}}.'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">defaultTest:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  assert:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    - type: is-json</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      value:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        type: object</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        required: [capital, population_millions]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">        properties:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          capital: { type: string }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">          population_millions: { type: number }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">tests:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: France }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Paris }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Japan }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Tokyo }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  - vars: { country: Brazil }</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    assert: [{ type: icontains, value: Bras }]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: 4-model matrix results table — GLM, gemma4, Qwen2.5 all PASS; qwen3.5:9B all FAIL with reasoning text" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-model-matrix-table-fcebe135ddd276ef881fc83a726c6b96.png" width="1001" height="972" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 13a.</strong> 12 test cases across 4 models — 9 pass, 3 fail. <code>qwen3.5:9B</code> emits its chain-of-thought instead of JSON.</p>
<p><span class="zoomImage__wrap"><img alt="Terminal: model matrix token summary — qwen3.5:9B burned 2,650 tokens vs ~200 for the others" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-model-matrix-summary-96e3ce6b439ae8d487aef486120dbe97.png" width="639" height="319" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 13b.</strong> The token breakdown tells the real story: the reasoning model cost 13× more for the same task.</p>
<table><thead><tr><th>Model</th><th>Strict JSON?</th><th>Notes</th><th>Tokens (3 calls)</th></tr></thead><tbody><tr><td><code>zai-org/GLM-5.2</code></td><td>✅ all pass</td><td>compact JSON</td><td>177</td></tr><tr><td><code>gemma4</code></td><td>✅ all pass</td><td>integer populations</td><td>207</td></tr><tr><td><code>Qwen2.5-3B-Instruct</code></td><td>✅ all pass</td><td>pretty-printed, precise</td><td>262</td></tr><tr><td><code>qwen3.5:9B</code></td><td>❌ all fail</td><td><strong>reasoning model</strong> — emits its "thinking" instead of raw JSON</td><td><strong>2,650</strong></td></tr></tbody></table>
<p>Two lessons fall out immediately:</p>
<ul>
<li class=""><strong><code>qwen3.5:9B</code> is a reasoning model.</strong> It "thinks out loud," so it breaks strict JSON
unless you strip the reasoning or use a response-format constraint. Picking it for
structured output would silently fail.</li>
<li class=""><strong>Cost varies ~13×.</strong> The reasoning model burned 2,650 tokens for the same task the others
did in ~200. For a JSON extraction job, the smaller models are both correct <em>and</em> far
cheaper.</li>
</ul>
<p>That's a model-selection decision you can now defend with data — for <em>your</em> workloads, not a
generic leaderboard.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>compare every model on your endpoint with objective, repeatable numbers</strong> —
accuracy, latency, and cost side by side.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-10--make-it-a-gate">Step 10 — Make it a gate<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#step-10--make-it-a-gate" class="hash-link" aria-label="Direct link to Step 10 — Make it a gate" title="Direct link to Step 10 — Make it a gate" translate="no">​</a></h2>
<p>Promptfoo exits <strong>non-zero (<code>100</code>)</strong> when any test fails. Verify this first:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> /dev/null </span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token file-descriptor important">&amp;1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"exit code: </span><span class="token string variable" style="color:#36acaa">$?</span><span class="token string" style="color:#e3116c">"</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: exit code: 100" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-exit-code-a3471bde65e7ebf24feaf0d964a3b7eb.png" width="652" height="70" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 14.</strong> Exit code 100 confirms Promptfoo signals failure reliably — exactly what CI needs.</p>
<p>Wire it up as a gate:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">npx </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> promptfoo@latest </span><span class="token builtin class-name">eval</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> promptfooconfig.yaml </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"✅ eval passed"</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"❌ eval failed — blocking"</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">exit</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: CI gate eval table — same 4-model results, 9 pass / 3 fail" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-ci-gate-table-7067b62a9b737ed79e3906b6da54ea2a.png" width="968" height="989" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 15a.</strong> The gate re-runs the matrix eval from cache — same 9/3 result.</p>
<p><span class="zoomImage__wrap"><img alt="Terminal: ❌ eval failed — blocking" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-ci-gate-result-8765593b4f037c480e7fcef844ff6eb4.png" width="959" height="647" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 15b.</strong> Promptfoo exits non-zero, the <code>||</code> branch fires, and the gate blocks.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>Never run the <code>exit 1</code> variant in your interactive shell — see <a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#3-exit-1-closes-your-ssh-session" class="">Troubleshooting #3</a>.</p></div></div>
<p>In CI, you don't even need the <code>exit 1</code> — the non-zero exit fails the job. On GitLab:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">eval</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">image</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> node</span><span class="token punctuation" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">22</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">script</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> npx </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">y promptfoo@latest eval </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">c promptfooconfig.yaml</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token comment" style="color:#999988;font-style:italic"># WEC_API_KEY is set as a masked CI/CD variable — never commit it.</span><br></div></code></pre></div></div>
<p>Now a prompt change that quietly breaks JSON output, or a model swap that doubles latency,
fails the pipeline before it reaches users.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>Your eval is now a <strong>deploy gate</strong> — regressions fail CI instead of reaching users.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real-errors">Troubleshooting (real errors)<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#troubleshooting-real-errors" class="hash-link" aria-label="Direct link to Troubleshooting (real errors)" title="Direct link to Troubleshooting (real errors)" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-bytestring-error-on-first-eval">1. ByteString error on first eval<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#1-bytestring-error-on-first-eval" class="hash-link" aria-label="Direct link to 1. ByteString error on first eval" title="Direct link to 1. ByteString error on first eval" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">API call error: TypeError: Cannot convert argument to a ByteString because</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">the character at index 32 has a value of 8232 which is greater than 255.</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: ByteString error, both cases ERROR" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-invisible-char-error-540ad4b2ab9242c120ae80870319df01.png" width="996" height="627" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> Every API call errors before reaching the server — the key never even leaves the machine.</p>
<p><strong>Cause:</strong> The pasted key contains an invisible Unicode character — U+2028 (line separator). Some terminals and clipboard managers insert it silently when copying from certain sources.</p>
<p><strong>Fix:</strong> Strip non-printable characters from the key:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">export</span><span class="token plain"> </span><span class="token assign-left variable" style="color:#36acaa">WEC_API_KEY</span><span class="token operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">$(</span><span class="token variable builtin class-name" style="color:#36acaa">printf</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'%s'</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token variable string" style="color:#e3116c">"</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable operator" style="color:#393A34">|</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable assign-left variable environment constant" style="color:#36acaa">LC_ALL</span><span class="token variable operator" style="color:#393A34">=</span><span class="token variable" style="color:#36acaa">C </span><span class="token variable function" style="color:#d73a49">tr</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable parameter variable" style="color:#36acaa">-cd</span><span class="token variable" style="color:#36acaa"> </span><span class="token variable string" style="color:#e3116c">'[:print:]'</span><span class="token variable" style="color:#36acaa">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">printf</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'len=%s\n'</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$(</span><span class="token string variable builtin class-name" style="color:#36acaa">printf</span><span class="token string variable" style="color:#36acaa"> %s </span><span class="token string variable string" style="color:#e3116c">"</span><span class="token string variable string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string variable string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable operator" style="color:#393A34">|</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable function" style="color:#d73a49">wc</span><span class="token string variable" style="color:#36acaa"> </span><span class="token string variable parameter variable" style="color:#36acaa">-c</span><span class="token string variable" style="color:#36acaa">)</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># len=25</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: key strip command + len=25 confirmation" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-key-strip-1a9c78d8c230c69db3e89eecfdfa24ca.png" width="712" height="70" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> After stripping, the key length confirms no extra characters remain. Re-run the eval — it passes.</p>
<hr>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-cost-assertion-errors-immediately">2. Cost assertion errors immediately<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#2-cost-assertion-errors-immediately" class="hash-link" aria-label="Direct link to 2. Cost assertion errors immediately" title="Direct link to 2. Cost assertion errors immediately" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Error: Cost assertion does not support providers that do not return cost</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal: cost assertion, 2 errors" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-cost-error-1615b4a5c8b0973274d32c6d658a2e12.png" width="953" height="931" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 10.</strong> The WEC Inference API doesn't return a cost field in its responses.</p>
<p><strong>Cause:</strong> Promptfoo calculates cost by looking up a price-per-token for the model in its built-in price list and multiplying by the tokens used. It has prices for the big hosted models but not for WEC's self-hosted model names, so it has no price to apply and the assertion fails. (Most APIs, including OpenAI, don't return a dollar cost in the response — tools compute it.)</p>
<p><strong>Fix:</strong> Skip the <code>cost</code> assertion entirely. Track spend from the <code>Total Tokens</code> line Promptfoo prints at the end of every run, and use <code>max_tokens</code> in the provider config to cap per-call token usage.</p>
<hr>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-exit-1-closes-your-ssh-session">3. <code>exit 1</code> closes your SSH session<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#3-exit-1-closes-your-ssh-session" class="hash-link" aria-label="Direct link to 3-exit-1-closes-your-ssh-session" title="Direct link to 3-exit-1-closes-your-ssh-session" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">❌ eval failed — blocking</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">logout</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Connection to 10.80.4.212 closed.</span><br></div></code></pre></div></div>
<p><strong>Cause:</strong> Running <code>|| { echo "❌ eval failed — blocking"; exit 1; }</code> directly in an interactive shell exits the shell process itself — which, over SSH, closes the connection.</p>
<p><strong>Fix:</strong> Put the gate in a non-interactive context: a <code>Makefile</code> target, an <code>npm</code> script, a git <code>pre-commit</code> hook, or a CI job. In those environments <code>exit 1</code> fails the job without touching your terminal. If you need to test the gate locally, use <code>|| echo "❌ eval failed"</code> (without <code>exit 1</code>) to see the output safely.</p>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Prove a model works</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You've gone from "looks good to me" to a real, automatable eval harness on WEC — with
deterministic checks, guardrails, schema validation, model-graded rubrics, a model matrix,
and a CI gate.</p>
<p>Coming up in this series:</p>
<ul>
<li class=""><strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/structured-output-evals-wiline-inference/">Trustworthy JSON: schema-validate your model's structured output</a></strong>
— <strong>now available (part 2):</strong> recover fenced and reasoning-wrapped output with transforms,
compare models for JSON reliability, and gate it all in CI.</li>
<li class=""><strong>Model-graded evals done right</strong> — calibrating <code>llm-rubric</code>, factuality scoring, and
multi-judge checks so your grader is trustworthy.</li>
<li class=""><strong>Catching hallucinations</strong> — pairing these techniques with WEC's
<a href="https://wec.wiline.com/resources/rag-hallucination-eval" target="_blank" rel="noopener noreferrer" class="">RAG hallucination evaluation</a>.</li>
<li class=""><strong>Evaluating audio</strong> — scoring Whisper transcription accuracy on the WEC Inference API.</li>
</ul>]]></content:encoded>
            <category>ai</category>
            <category>evals</category>
            <category>promptfoo</category>
            <category>inference</category>
            <category>testing</category>
            <category>observability</category>
        </item>
        <item>
            <title><![CDATA[Run OpenClaw on WEC Models]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/</guid>
            <pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Swap the closed provider for WiLine's own inference: point your self-hosted OpenClaw agent at WEC Models — same box, a base-URL/key/model change, no per-token lock-in.]]></description>
            <content:encoded><![CDATA[<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Deploy your own AI assistant</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Real HTTPS + auth</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Chat from Telegram</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Private mesh access</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">5</span><span class="skillTracker__skill" data-state="current">Run it on WEC models</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Chat from WhatsApp</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><svg xmlns="http://www.w3.org/2000/svg" width="242" height="255" fill="none" viewBox="0 0 242 255" class="tutorialHero__wiline"><path fill="#4E8EF7" d="M56.711 11.293A195 195 0 0 1 41.899 4.79c4.788 1.85 9.71 4.028 14.812 6.504M73.807 29.35c-1.302-1.446-2.666-3.233-4.02-5.38 1.326 1.44 2.642 3.24 4.02 5.38M69.761 23.354c-.832-.585-1.752-1.472-2.558-2.714.9.567 1.685 1.49 2.558 2.714M62.97 15.774a10.7 10.7 0 0 1-3.158-1.841c.993.299 1.986.956 3.159 1.841M59.715 13.346c-.818-.194-1.782-.674-2.829-1.494.84.175 1.761.691 2.83 1.494M39.317 3.782c-.524.192-1.413.302-2.455.14.594-.255 1.342-.238 2.455-.14M74.906 31.339c-.334-.153-.73-.61-1.117-1.398.358.143.706.619 1.117 1.398M66.024 18.878c-.239.1-.557.05-.752-.276.35-.201.532-.069.752.276M71.773 150.625c3.363 7.291 7.185 14.411 9.983 21.914 4.474 11.997 11.167 20.832 24.296 24.757 10.62 3.175 20.369 1.795 30.093-1.816 9.937-3.689 15.165-11.978 18.501-21.541 4.349-12.466 9.385-24.443 19.668-33.446 11.32-9.909 24.639-14.788 39.429-15.63 8.3-.473 16.649-.081 25.444.444.472 15.673.385 30.825.55 45.974.038 3.499-1.049 4.719-4.557 4.543-4.982-.25-9.987-.079-14.98.009-.209.004-.4.991-.6 1.521-23.106 1.695-37.691 15.22-47.961 34.651-3.387 6.408-5.913 13.38-10.013 19.273-4.368 6.277-10.134 11.582-15.853 17.196-6.439 1.421-12.244 3.342-18.202 4.416-12.627 2.274-24.661.457-35.764-6.356-1.028-.631-2.395-.709-3.604-1.043 0 0-.117-.11-.047-.495-.818-.973-1.706-1.562-2.593-2.15 0 0 .09-.044.05-.335-.513-.545-.988-.798-1.463-1.052 0 0-.07-.123.012-.492-.561-.894-1.205-1.421-1.848-1.947 0 0 .134-.068.102-.28-.321-.442-.61-.671-.898-.901 0 0 .086-.007.074-.336-2.05-3.429-4.089-6.529-6.127-9.629 0 0 .094-.017.123-.365-3.013-6.117-6.056-11.886-9.1-17.655 0 0 .096-.01.088-.342-1.978-4.073-3.89-7.848-5.997-11.51-.246-.428-1.572-.235-2.398-.329 0 0-.226-.202-.114-.613-.213-.499-.537-.587-.861-.674 0 0-.032-.101.02-.455-.62-.846-1.291-1.339-1.964-1.832 0 0 .133-.083.119-.372-2.476-4.624-5.645-8.048-10.914-8.777 0 0 .108-.031.01-.326-1.673-1.444-3.105-2.918-4.85-3.688-9.751-4.308-20.013-5.643-30.63-5.23-7.305.285-7.316.051-7.317-7.446q-.002-18.496-.002-36.992v-3.57h36.106s-.08.019.133.283c1.43.415 2.646.567 3.863.719 0 0-.077.022.113.296 1.804.591 3.417.908 5.03 1.225 0 0 .139-.037.152.369 1.592.962 3.17 1.517 4.748 2.073 0 0-.091.027.052.267.735.415 1.328.59 1.92.765 0 0-.076.033.053.246.477.325.825.438 1.173.551 0 0-.149.018-.07.246.418.239.757.251 1.096.263 0 0 .23.058.189.465 1.522 1.319 3.084 2.231 4.647 3.143 0 0-.105.033-.015.316 2.723 2.764 5.356 5.244 7.989 7.724 0 0-.104.062-.094.352 1.01 1.401 2.01 2.512 3.01 3.624"></path><path fill="#4E8EF7" d="M214.77 52.819c-3.343.846-6.723 1.574-10.024 2.562-11.388 3.408-20.567 10.142-27.166 19.865-5.097 7.511-9.621 15.483-13.685 23.608-6.809 13.613-15.725 24.809-31.343 28.198-24.707 5.362-45.22-.126-57.852-24.564C69.466 92.363 63.8 82.375 57.349 73c-3.369-4.896-8.866-8.326-13.679-13.041-1.11-1.409-1.81-2.472-2.794-2.937-10.697-5.056-21.87-7.3-33.686-5.344-3.877.641-5.72-.297-5.615-4.86.294-12.816.26-25.646.019-38.464C1.509 3.874 3.15 2.5 7.429 2.716c9.774.492 19.564.678 29.347.989 0 0-.076.026.082.257 1.046.122 1.935.012 2.825-.098.575.043 1.15.086 2.038.692 5.265 2.72 10.218 4.877 15.17 7.034 0 0-.108.03-.016.317 1.058.765 2.022 1.245 2.987 1.724 0 0-.113.048-.082.354 1.144.876 2.258 1.446 3.372 2.017 0 0 .18.145.097.506.223.404.53.447.837.49 0 0 .214.13.144.504.244.426.558.477.873.528 0 0 .171.124.108.485.256.412.574.464.893.515 0 0 .167.127.105.493.265.437.592.509.919.58 0 0 .078.108.019.5.861 1.28 1.782 2.166 2.702 3.053 0 0-.101.061-.087.368 1.379 2.093 2.743 3.88 4.107 5.666 0 0-.094.011-.092.297.4.742.795 1.2 1.19 1.656 0 0-.121.02-.165.337.685 1.432 1.413 2.549 2.14 3.665 0 0-.11.024-.129.299.326.776.67 1.277 1.014 1.78 0 0-.084-.005-.1.367.933 4.079.311 8.497 4.648 10.67 4.69 12.164 12.107 21.794 25.852 23.497 19.618 2.431 35.321.062 45.299-24.554 3.398-8.383 7.099-16.902 12.232-24.264 8.353-11.979 21.046-17.795 35.832-20.32 1.666.417 2.649.651 3.624.622 9.93-.298 19.865-.513 29.785-1.032 3.818-.2 4.825 1.198 4.746 4.853-.218 9.967.004 19.944-.109 29.915-.052 4.583 1.149 10.606-1.239 13.302-2.044 2.308-8.394.86-12.842.967-3.82.092-7.644.036-11.467.045.065.31.097.633.213.922.034.084.306.072.469.105M37.585 127.354c-11.585.344-23.368.344-35.907.344v3.57q0 18.496.002 36.992c0 7.497.012 7.731 7.318 7.446 10.616-.413 20.878.922 30.63 5.23 1.743.77 3.176 2.244 4.84 3.744-13.655-7.873-28.93-7.767-44.468-7.398v-52.503c12.516.308 25.063-1.222 37.585 2.575"></path><path fill="#4E8EF7" d="M36.619 3.401c-9.626-.007-19.415-.193-29.19-.685C3.151 2.5 1.51 3.874 1.594 8.354c.241 12.818.275 25.648-.018 38.464-.105 4.563 1.737 5.501 5.614 4.86 11.816-1.956 22.99.288 33.686 5.344.984.465 1.685 1.529 2.616 2.66-13.39-7.538-28.284-7.343-43.479-6.78V0C12.314 1.045 24.388 2.071 36.62 3.401M215.232 52.852c-.625-.066-.897-.054-.931-.138-.116-.29-.148-.612-.212-.922 3.822-.01 7.646.047 11.467-.045 4.448-.108 10.797 1.341 12.842-.967 2.387-2.696 1.186-8.719 1.238-13.302.114-9.971-.109-19.948.109-29.915.08-3.655-.928-5.052-4.746-4.853-9.919.519-19.855.734-29.785 1.032-.975.03-1.958-.205-3.228-.58C214.559 1.945 227.423.991 240.73.003v52.882c-8.276 0-16.656 0-25.498-.033M220.071 177.398c-.271-.573-.08-1.561.129-1.565 4.993-.088 9.998-.259 14.98-.009 3.508.176 4.595-1.044 4.557-4.543-.165-15.149-.078-30.301-.088-45.911.458.372 1.32 1.202 1.324 2.036.079 16.424.059 32.848.059 50.036-6.984 0-13.737 0-20.961-.044M88.39 245.741c1.022.084 2.39.162 3.417.793 11.102 6.813 23.137 8.63 35.763 6.356 5.959-1.073 11.764-2.995 17.871-4.34-5.612 2.132-11.343 5.166-17.325 5.79-13.898 1.451-27.803 1.213-39.725-8.599M66.556 210.212c2.975 5.411 6.018 11.18 9.02 17.344-3.011-5.4-5.982-11.193-9.02-17.344M58.341 197.934c.666-.167 1.992-.36 2.238.068 2.107 3.662 4.018 7.437 5.985 11.557-2.69-3.535-5.376-7.449-8.223-11.625M44.637 185.241c5.099.438 8.268 3.862 10.737 8.564-3.518-2.513-7.043-5.393-10.737-8.564M82.29 48.422c-4.252-1.834-3.63-6.252-4.548-10.37 1.508 3.069 2.986 6.55 4.548 10.37M68.757 146.326c-2.533-2.157-5.166-4.637-7.882-7.455 2.539 2.151 5.16 4.641 7.882 7.455M75.532 228.226a122 122 0 0 1 6.047 9.319c-1.993-2.742-3.986-5.854-6.047-9.319M71.695 150.312c-.922-.799-1.921-1.91-2.906-3.366.952.788 1.89 1.921 2.906 3.366M46.663 130.007c-1.353-.103-2.966-.42-4.767-1.051 1.377.07 2.941.454 4.767 1.051M51.647 132.369a13.1 13.1 0 0 1-4.501-1.77c1.48.227 2.902.852 4.5 1.77M60.724 138.321a17.7 17.7 0 0 1-4.395-2.781c1.501.609 2.869 1.551 4.395 2.781M76.889 35.31c-.674-.781-1.402-1.898-2.068-3.39.711.768 1.362 1.912 2.068 3.39M85.668 243.124c.782.31 1.67.899 2.423 1.825-.862-.29-1.59-.919-2.423-1.825M41.596 128.372c-1.033.176-2.25.024-3.68-.432 1.023-.169 2.26-.032 3.68.432M55.367 194.37c.568.222 1.24.715 1.808 1.548-.637-.199-1.17-.738-1.808-1.548M82.397 239.295c.56.251 1.204.778 1.71 1.628-.633-.235-1.13-.794-1.71-1.628M84.291 241.679c.334.034.809.287 1.298.875-.376.004-.766-.326-1.298-.875M53.64 133.426c-.438.094-1.03-.081-1.764-.543.441-.101 1.026.086 1.764.543M77.805 37.409c-.322-.188-.666-.69-.978-1.52.34.182.648.693.978 1.52M81.588 238.108c.218-.039.507.19.823.707-.233.045-.493-.196-.823-.707M55.906 134.832c-.198.157-.537.145-.92-.161.262-.29.537-.195.92.161M57.276 196.552c.254-.079.578.009.756.361-.377.171-.557.018-.756-.361M54.888 134.25c-.215.129-.563.017-1.038-.351.217-.133.56-.012 1.038.351M64.014 16.858a.545.545 0 0 1-.71-.247c.344-.19.51-.067.71.247M65.027 17.882a.574.574 0 0 1-.734-.272c.35-.193.525-.063.734.272M67.045 19.939c-.244.093-.57.021-.777-.329.358-.192.55-.044.777.329"></path></svg></div>
<p>Through <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Parts 1–4</a> you deployed
OpenClaw, secured it with HTTPS, added Telegram, and made it private over a NetBird
mesh — all pointed at <strong>OpenAI</strong>. This part swaps the model out from under it:
point the same agent at <strong>WEC Models</strong> — WiLine's own OpenAI-compatible inference —
and run it on an open-weight model, <strong>Llama 3.1 8B Instruct</strong>. Same box, no rebuild
— just a base-URL, key, and model change via the OpenClaw CLI.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues on the <strong>same WEC Instance</strong> from Parts 1–4. WEC Models is an
<strong>OpenAI-compatible</strong> endpoint at <code>https://inference.wiline.com/v1</code>; every command
below was run against a live OpenClaw gateway container
(<code>openclaw-openclaw-gateway-1</code>).</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-point-openclaw-at-wec-models">Why point OpenClaw at WEC Models<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#why-point-openclaw-at-wec-models" class="hash-link" aria-label="Direct link to Why point OpenClaw at WEC Models" title="Direct link to Why point OpenClaw at WEC Models" translate="no">​</a></h2>
<p>If you self-hosted the agent to begin with, you already care about control — not
depending on someone else's cloud, not being one pricing-page email away from a
rebuild. But there's a gap in that story: the agent runs on your box, and every
single message it handles is still shipped off to OpenAI to think. Self-hosting
the agent and outsourcing the model is only half the job. This part closes it.</p>
<ul>
<li class=""><strong>One model, end to end on WiLine</strong> — your agent <em>and</em> its model run on WEC; no
third-party provider on the hot path.</li>
<li class=""><strong>Open weights</strong> — Llama 3.1 isn't a closed box; you're not locked to a single
vendor's API to keep the agent running.</li>
<li class=""><strong>It's a config change, not a migration</strong> — OpenClaw already speaks the OpenAI
API, and WEC Models is OpenAI-compatible, so you keep your agent, prompts, and
channels untouched.</li>
</ul>
<p>It's also just cheaper. WEC's own catalog prices <code>Llama3.1-8B-Instruct</code> at
<strong>$0.15 / 1M input tokens · $0.20 / 1M output tokens</strong>. OpenAI doesn't publish a
standalone rate for <code>gpt-5.5</code> (the model OpenClaw defaults to in
<a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Part 1</a>) any more, but its closest
current family member — GPT-5.6 "Terra," pitched by OpenAI as
<a class="" href="https://development-wec.wiline.com/docs/news/gpt-5-6-token-economics/">"performance competitive with GPT-5.5"</a> — lists at
<strong>$2.50 / 1M in · $15 / 1M out</strong>. That's an approximate comparison, not an
apples-to-apples benchmark — the honest move, as always on this blog, is to
<a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">eval it on your own workload</a>
rather than trust either vendor's number. But the direction is clear enough to be
worth the ten minutes this takes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">OpenClaw running from <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Parts 1–4</a> (any
of the setups works). This guide assumes the gateway container is named
<code>openclaw-openclaw-gateway-1</code>.</li>
<li class="">A <strong>WEC API key</strong>.</li>
<li class="">Shell access to the OpenClaw box.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--get-a-wec-api-key">Step 1 — Get a WEC API key<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#step-1--get-a-wec-api-key" class="hash-link" aria-label="Direct link to Step 1 — Get a WEC API key" title="Direct link to Step 1 — Get a WEC API key" translate="no">​</a></h2>
<p>See <a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/inference/api_keys/">Inference → API Keys</a> if you
don't already have one (the same key from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">Part 1 of the evals series</a>
works fine here).</p>
<p><span class="zoomImage__wrap"><img alt="The WiLine portal API Keys page" src="https://development-wec.wiline.com/docs/assets/images/promptfoo-api-key-a1aca6bfffa7335651ab59ec1c07d8f4.png" width="1642" height="866" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 1.</strong> Create a key under Inference → API Keys, then copy it.</p>
<p>Keep it out of your shell history — put it in a file instead of exporting it
directly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'WEC_API_KEY=sk-wec-...'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token plain"> env.local</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token builtin class-name">source</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'^#'</span><span class="token plain"> env.local </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^/export /'</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--find-the-model-id">Step 2 — Find the model ID<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#step-2--find-the-model-id" class="hash-link" aria-label="Direct link to Step 2 — Find the model ID" title="Direct link to Step 2 — Find the model ID" translate="no">​</a></h2>
<p>Query the OpenAI-compatible <code>/v1/models</code> endpoint to see what's live on your WEC
Instance:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-H</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Authorization: Bearer </span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> https://inference.wiline.com/v1/models</span><br></div></code></pre></div></div>
<p>The response lists every model available to your key, alongside the id this
tutorial uses:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"data"</span><span class="token operator" style="color:#393A34">:</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ...</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"id"</span><span class="token operator" style="color:#393A34">:</span><span class="token string" style="color:#e3116c">"Llama3.1-8B-Instruct"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"object"</span><span class="token operator" style="color:#393A34">:</span><span class="token string" style="color:#e3116c">"model"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"created"</span><span class="token operator" style="color:#393A34">:</span><span class="token number" style="color:#36acaa">1677610602</span><span class="token punctuation" style="color:#393A34">,</span><span class="token property" style="color:#36acaa">"owned_by"</span><span class="token operator" style="color:#393A34">:</span><span class="token string" style="color:#e3116c">"openai"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>Model ids on WEC aren't always the display name shown in the portal — always take
the exact <code>id</code> from this response, not a guess.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--point-openclaw-at-wec-models">Step 3 — Point OpenClaw at WEC Models<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#step-3--point-openclaw-at-wec-models" class="hash-link" aria-label="Direct link to Step 3 — Point OpenClaw at WEC Models" title="Direct link to Step 3 — Point OpenClaw at WEC Models" translate="no">​</a></h2>
<p>Don't hand-edit <code>openclaw.json</code> — use <code>openclaw config set</code>. It validates the
whole file on every write, so a typo fails loudly instead of quietly breaking
the gateway. A custom provider needs its base URL, its API style, and its model
list declared <strong>together</strong>, in one write — the schema rejects a provider that's
missing any of the three, so build the full object up front:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw config </span><span class="token builtin class-name">set</span><span class="token plain"> models.providers.wec </span><span class="token string" style="color:#e3116c">'{</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  "baseUrl": "https://inference.wiline.com/v1",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  "api": "openai-completions",</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  "models": [</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    {"id": "Llama3.1-8B-Instruct", "name": "Llama 3.1 8B Instruct"}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  ]</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">}'</span><span class="token plain"> --strict-json</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Updated models.providers.wec. Change will apply without restarting the gateway.</span><br></div></code></pre></div></div>
<p>That registers WEC as a provider, but it's not authenticated yet. Don't put the
key straight into that JSON — OpenClaw's compose setup keeps API keys out of
environment variables entirely and stores them in a dedicated, file-backed
secrets directory instead (<code>OPENCLAW_AUTH_PROFILE_SECRET_DIR</code>, mounted into the
container). Hand the key to OpenClaw's own auth store and let it do the wiring:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$WEC_API_KEY</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw models auth paste-api-key </span><span class="token parameter variable" style="color:#36acaa">--provider</span><span class="token plain"> wec</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Updated config: $OPENCLAW_HOME/.openclaw/openclaw.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Backup: $OPENCLAW_HOME/.openclaw/openclaw.json.bak</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Auth profile: wec:manual (wec/api_key)</span><br></div></code></pre></div></div>
<p>Check that the provider and its auth resolved together, then make the model
the default so every agent picks it up without extra config:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw models list</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw models </span><span class="token builtin class-name">set</span><span class="token plain"> wec/Llama3.1-8B-Instruct</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Model                                      Input      Ctx         Local Auth  Tags</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">wec/Llama3.1-8B-Instruct                   text       200k        no    yes   configured</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">openai/gpt-5.5                             text       200k        no    yes   configured,alias:GPT</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Updated config: $OPENCLAW_HOME/.openclaw/openclaw.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Backup: $OPENCLAW_HOME/.openclaw/openclaw.json.bak</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Default model: wec/Llama3.1-8B-Instruct</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Terminal showing openclaw models list with wec/Llama3.1-8B-Instruct configured and set as default" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wec-models-list-bb96e6c9a1dfe7d0c98be4a6b5b40ae6.png" width="682" height="449" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 2.</strong> <code>wec/Llama3.1-8B-Instruct</code>, authenticated and set as the default model.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--verify">Step 4 — Verify<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#step-4--verify" class="hash-link" aria-label="Direct link to Step 4 — Verify" title="Direct link to Step 4 — Verify" translate="no">​</a></h2>
<p>OpenClaw can run more than one agent per gateway, so every message needs a
target — pass <code>--agent</code>, matching whatever <code>openclaw agents list</code> calls yours
(<code>main</code>, unless you renamed it):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw agents list</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Agents:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">- main (default)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Workspace: $OPENCLAW_HOME/.openclaw/workspace</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Model: wec/Llama3.1-8B-Instruct</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Routing rules: 0</span><br></div></code></pre></div></div>
<p>Send it a message and see who answers:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw agent </span><span class="token parameter variable" style="color:#36acaa">--agent</span><span class="token plain"> main </span><span class="token parameter variable" style="color:#36acaa">--message</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"What is the capital of France?"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Paris is the capital of France.</span><br></div></code></pre></div></div>
<p>That's WEC, on Llama 3.1, with no OpenAI in the loop.</p>
<p><span class="zoomImage__wrap"><img alt="Terminal showing the agent answering &amp;quot;Paris is the capital of France.&amp;quot; after switching to WEC" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wec-agent-verified-e5125e1d417f258ba73f9d11b78244ff.png" width="675" height="50" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span>
<strong>Figure 3.</strong> The agent, running on Llama 3.1 8B Instruct via WEC.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5-optional--turn-off-thinking-mode">Step 5 (optional) — Turn off thinking mode<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#step-5-optional--turn-off-thinking-mode" class="hash-link" aria-label="Direct link to Step 5 (optional) — Turn off thinking mode" title="Direct link to Step 5 (optional) — Turn off thinking mode" translate="no">​</a></h2>
<p>Want it snappier? Turn off thinking mode — it's a slash command inside the
chat, not a shell command:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw agent </span><span class="token parameter variable" style="color:#36acaa">--agent</span><span class="token plain"> main </span><span class="token parameter variable" style="color:#36acaa">--message</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"/think off"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Thinking mode has been turned off.</span><br></div></code></pre></div></div>
<p>Or make it stick for every session:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw config </span><span class="token builtin class-name">set</span><span class="token plain"> agents.defaults.thinkingDefault </span><span class="token string" style="color:#e3116c">"off"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Updated agents.defaults.thinkingDefault. No gateway restart needed.</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real">Troubleshooting (real)<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#troubleshooting-real" class="hash-link" aria-label="Direct link to Troubleshooting (real)" title="Direct link to Troubleshooting (real)" translate="no">​</a></h2>
<p><strong>"custom model providers must declare models"</strong> — declare <code>baseUrl</code>, <code>api</code>,
and <code>models</code> together, in one <code>config set</code> call. A custom provider missing any
of the three gets rejected; you can't add <code>models</code> in a follow-up write.</p>
<p><strong>"models.providers.wec.apiKey is unresolved in the active runtime snapshot"</strong>
— don't set <code>apiKey</code> on the provider object; let <code>models auth paste-api-key</code>
own it (Step 3). Already set one? Remove it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token builtin class-name">exec</span><span class="token plain"> openclaw-openclaw-gateway-1 openclaw config </span><span class="token builtin class-name">unset</span><span class="token plain"> models.providers.wec.apiKey</span><br></div></code></pre></div></div>
<p><strong>"No target session selected"</strong> — always pass <code>--agent &lt;id&gt;</code> with
<code>openclaw agent</code>. Check the id with <code>openclaw agents list</code> first if you're not
sure (<code>main</code>, unless you renamed it).</p>
<p><strong><code>-bash: /think: No such file or directory</code></strong> — <code>/think</code> is a slash command
for inside the chat, not a shell command. Pass it through <code>--message</code> instead,
as in Step 5.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>With the agent running on our own model, the next step is <strong>measuring</strong> it —
<a class="" href="https://development-wec.wiline.com/docs/tutorials/eval-models-promptfoo-wiline-inference/">evaluating answer quality with Promptfoo + WEC Models</a>.</p>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Run it on WEC models</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>openclaw</category>
            <category>wec-models</category>
            <category>inference</category>
        </item>
        <item>
            <title><![CDATA[Self-host the Hermes Agent with persistent memory]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/</guid>
            <pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Deploy Nous Research's open-source Hermes Agent on your WEC Instance — with SQLite-backed persistent memory that survives a full reboot. Real install, model config, and a memory-survives-restart test.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/hermesagent-c76bbe81610ae2e70b0950e0f96bec8a.png" alt="Hermes Agent"><span class="tutorialHero__plus">+</span><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/SQLite370-e02c30e84f9e098787bb57701b0b7232.png" alt="SQLite"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 2 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->2</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->2<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting Hermes</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">Agent with persistent memory</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/telegram-on-hermes/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Telegram on Hermes</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p><a href="https://hermes-agent.nousresearch.com/" target="_blank" rel="noopener noreferrer" class="">Hermes Agent</a> is Nous Research's open-source
(MIT) AI agent — "the agent that grows with you." Its standout feature is
<strong>persistent memory</strong>: it learns your projects and <strong>doesn't forget across
restarts</strong>. This guide deploys it on the <strong>same WEC Instance</strong> you already use for
OpenClaw, points it at a model, and proves the memory survives a full reboot.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Runs on the <strong>same WEC Instance</strong> from the OpenClaw series — no new VM. The installer
pulls its own Python (via <code>uv</code>), Node.js browser tools, and a Playwright Chromium
automatically. State lives in <code>~/.hermes/</code>. The messaging gateway runs as a <strong>systemd
user service</strong>, so it coexists with the OpenClaw Docker stack. Captured live on Ubuntu
22.04 with <code>hermes</code> <strong>v0.17.0</strong>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-persistent-memory-matters">Why persistent memory matters<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#why-persistent-memory-matters" class="hash-link" aria-label="Direct link to Why persistent memory matters" title="Direct link to Why persistent memory matters" translate="no">​</a></h2>
<p>An agent that forgets on every restart is a toy. Hermes keeps a <strong>durable store on
disk</strong> under <code>~/.hermes/</code>:</p>
<ul>
<li class=""><strong><code>~/.hermes/memories/MEMORY.md</code></strong> — a human-readable file of facts the agent has
chosen to remember.</li>
<li class=""><strong><code>~/.hermes/state.db</code></strong> — a <strong>SQLite</strong> database holding every session and message,
with <strong>FTS5 full-text search</strong> indexes layered on top so the agent can search its own
history.</li>
</ul>
<p>A deploy, crash, or reboot doesn't wipe what it learned. That's the difference between
a chat demo and an agent you can rely on day to day — and it's exactly what we test at
the end.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-moving-parts">The moving parts<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#the-moving-parts" class="hash-link" aria-label="Direct link to The moving parts" title="Direct link to The moving parts" translate="no">​</a></h2>
<p>Everything runs on one WEC Instance. Hermes talks to a model over the WEC Inference API,
keeps its memory on local disk, and runs as a systemd user service — so it restarts itself
after a reboot and its memory is still there:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">An existing <strong>WEC Instance</strong> with shell access (the OpenClaw box works).</li>
<li class="">A model: either a <strong>Nous Portal</strong> login, or your own provider API key (we use an
OpenAI key here; <strong>WEC Models</strong> with <code>glm-5.2</code> works too).</li>
<li class="">Nothing to pre-install — the installer handles Python, Node.js, and the browser
engine.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--install-hermes">Step 1 — Install Hermes<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#step-1--install-hermes" class="hash-link" aria-label="Direct link to Step 1 — Install Hermes" title="Direct link to Step 1 — Install Hermes" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://hermes-agent.nousresearch.com/install.sh </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">bash</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Running the install one-liner — the Hermes installer starts resolving and downloading packages" src="https://development-wec.wiline.com/docs/assets/images/hermes-install-start-1f3d29fcdd9d66dd1e5c1bc28b3c9e5d.png" width="660" height="341" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The installer resolves a Python virtual environment with <code>uv</code>, installs the Node.js
browser tools, downloads a Playwright Chromium, syncs the bundled skills, and drops a
<code>hermes</code> launcher into <code>~/.local/bin</code>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">✓ Main package installed (hash-verified via uv.lock)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ All dependencies installed</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ Browser engine setup complete</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ Installed hermes launcher → ~/.local/bin/hermes</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">✓ Configuration directory ready: ~/.hermes/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">→ Starting setup wizard...</span><br></div></code></pre></div></div>
<p>It then launches the setup wizard automatically. Reload your shell afterward so the
<code>hermes</code> command is on your <code>PATH</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">source</span><span class="token plain"> ~/.bashrc</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--run-the-setup-wizard">Step 2 — Run the setup wizard<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#step-2--run-the-setup-wizard" class="hash-link" aria-label="Direct link to Step 2 — Run the setup wizard" title="Direct link to Step 2 — Run the setup wizard" translate="no">​</a></h2>
<p>The wizard walks through setup type, terminal backend, and messaging. On a box that
already ran OpenClaw, it offers to <strong>import</strong> the old config first — you can preview
what would be migrated before anything changes.</p>
<p>The first choice is <strong>how to set up Hermes</strong>:</p>
<ul>
<li class=""><strong>Quick Setup (Nous Portal)</strong> — one OAuth login, no API keys. The Portal bundles a
model and a tool gateway. <strong>Requires an active Nous Portal subscription.</strong></li>
<li class=""><strong>Full setup (bring your own keys)</strong> — you configure providers and paste your own
keys.</li>
</ul>
<p><span class="zoomImage__wrap"><img alt="The &amp;quot;How would you like to set up Hermes?&amp;quot; prompt with Quick Setup, Full setup, and Blank Slate options" src="https://development-wec.wiline.com/docs/assets/images/hermes-setup-choice-0db3a3f64faec3d72e3477b075af1901.png" width="658" height="125" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>What we actually did</div><div class="admonitionContent_BuS1"><p>We chose <strong>Quick Setup</strong> first. The Portal login showed a device code and sat at
<em>"Waiting for approval (polling)"</em> — it needs an active Portal subscription, which this
account didn't have. We pressed <strong>Ctrl+C</strong> to cancel and instead <strong>brought our own
model in Step 3</strong> (below) with an OpenAI key. If you <em>do</em> have a Portal plan, complete
the login here and you can skip Step 3.</p></div></div>
<p>For the rest of the wizard:</p>
<ul>
<li class=""><strong>Terminal backend:</strong> <em>Local</em> — commands run directly on this machine.</li>
<li class=""><strong>Messaging:</strong> <em>Skip</em> for now (you can wire up Telegram/Discord later with
<code>hermes setup gateway</code>).</li>
</ul>
<p>The wizard applies sensible agent defaults (max iterations, a 50% context-compression
threshold) and finishes with a tool-availability summary.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--connect-a-model">Step 3 — Connect a model<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#step-3--connect-a-model" class="hash-link" aria-label="Direct link to Step 3 — Connect a model" title="Direct link to Step 3 — Connect a model" translate="no">​</a></h2>
<p>Out of the box the default model routes through the Nous Portal / OpenRouter and needs
a key. To use <strong>your own provider</strong> instead, run:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hermes setup model</span><br></div></code></pre></div></div>
<p>Then select <strong>OpenAI ▸ OpenAI API</strong>, paste your API key when prompted (it's written to
<code>~/.hermes/.env</code> — never echoed), accept the default base URL
<code>https://api.openai.com/v1</code>, and pick a model — we used <strong><code>gpt-5.5</code></strong>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">API key saved.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Base URL [https://api.openai.com/v1]:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Select default model:</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> → (●) gpt-5.5</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Choosing the default model in hermes setup model — gpt-5.5 selected from the OpenAI list" src="https://development-wec.wiline.com/docs/assets/images/hermes-model-select-2b0c5452d2e217ff8692c022badd9b28.png" width="661" height="545" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Use WEC Models instead</div><div class="admonitionContent_BuS1"><p>To run inference on WiLine's own endpoint, choose <strong>Custom endpoint</strong>, set the base URL
to <code>https://inference.wiline.com/v1</code>, paste your WEC API key, and use a model like
<code>glm-5.2</code>. Same OpenAI-compatible flow.</p></div></div>
<p>Confirm the model and key are healthy:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hermes doctor</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">◆ Configuration Files</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ ~/.hermes/.env file exists</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ API key or custom endpoint configured</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">◆ Memory Provider</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ✓ Built-in memory active (no external provider configured — this is fine)</span><br></div></code></pre></div></div>
<p>The remaining <code>⚠</code> lines in <code>doctor</code> are optional tool integrations (web search,
Discord, image gen) — not needed for this guide.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--chat-and-save-a-memory">Step 4 — Chat and save a memory<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#step-4--chat-and-save-a-memory" class="hash-link" aria-label="Direct link to Step 4 — Chat and save a memory" title="Direct link to Step 4 — Chat and save a memory" translate="no">​</a></h2>
<p>Start an interactive session:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hermes</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The Hermes Agent TUI welcome screen — ASCII logo, available tools and skills, model gpt-5.5, and the chat prompt" src="https://development-wec.wiline.com/docs/assets/images/hermes-tui-welcome-842b22f5c733599e17b84cde75db4933.png" width="657" height="814" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Send it a fact and ask it to remember — watch for the <code>🧠 memory</code> tool to fire:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">In the Hermes TUI</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">● Please save to your long-term memory: my WEC project codename is</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  Bluefin-7, running on the OpenClaw box.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  ┊ 🧠 memory    ?  0.0s</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">╭─ ⚕ Hermes ─────────────────────────────────────────────────────────╮</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    Saved to long-term memory: your WEC project codename is Bluefin-7,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    running on the OpenClaw box.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">╰─────────────────────────────────────────────────────────────────────╯</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Hermes firing the memory tool and confirming the fact was saved to long-term memory" src="https://development-wec.wiline.com/docs/assets/images/hermes-memory-save-458cefa8aaf46e0d1cc554418a3acc38.png" width="663" height="218" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>That write creates <code>~/.hermes/memories/MEMORY.md</code>. Exit with <code>/exit</code> or <code>Ctrl+D</code>.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Paste gotcha</div><div class="admonitionContent_BuS1"><p>The TUI submits on newline, so pasting multi-line text can send a half-typed message.
If a prompt only catches a fragment, <strong>type it on one line</strong> by hand.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--prove-memory-survives-a-reboot">Step 5 — Prove memory survives a reboot<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#step-5--prove-memory-survives-a-reboot" class="hash-link" aria-label="Direct link to Step 5 — Prove memory survives a reboot" title="Direct link to Step 5 — Prove memory survives a reboot" translate="no">​</a></h2>
<p>This is the whole point. The Hermes gateway is installed as a <strong>systemd user service</strong>
with <strong>linger enabled</strong>, so it comes back automatically after a reboot — no login
required. Restart the service, then reboot the whole box to make the test airtight:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> restart hermes-gateway   </span><span class="token comment" style="color:#999988;font-style:italic"># restart just the gateway service</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">reboot</span><span class="token plain">                               </span><span class="token comment" style="color:#999988;font-style:italic"># then reboot the entire box</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Restarting the gateway and rebooting the box — the SSH connection closes as the host goes down" src="https://development-wec.wiline.com/docs/assets/images/hermes-reboot-ae228254579427ae3018547803f83c62.png" width="616" height="74" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Reconnect once the box is up and confirm the service restarted on its own:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> status hermes-gateway --no-pager</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">● hermes-gateway.service - Hermes Agent Gateway - Messaging Platform Integration</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">     Loaded: loaded (...; enabled; vendor preset: enabled)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">     Active: active (running) since Thu 2026-06-25 21:21:54 UTC; 1min 36s ago</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="systemctl status showing hermes-gateway active and running since boot, started automatically by linger after the reboot" src="https://development-wec.wiline.com/docs/assets/images/hermes-gateway-status-4da5472c10ec41fe81cfc64298589f10.png" width="1001" height="272" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Now open a <strong>fresh</strong> session and ask — no prior context, brand-new session ID:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">hermes</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">In the Hermes TUI</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">● What's my WEC project codename, and which box does it run on?</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">╭─ ⚕ Hermes ─────────────────────────────────────────────────────────╮</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    Your WEC project codename is Bluefin-7, and it runs on the</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    OpenClaw box.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">╰─────────────────────────────────────────────────────────────────────╯</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="A fresh Hermes session after reboot correctly recalling the Bluefin-7 codename from disk" src="https://development-wec.wiline.com/docs/assets/images/hermes-reboot-recall-eac2b093a48d12569fa730340f5751d3.png" width="652" height="300" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>It recalled the fact from disk after a full reboot. Inspect where that durable state
lives:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> ~/.hermes/memories/MEMORY.md</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">file</span><span class="token plain"> ~/.hermes/state.db </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">du</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-h</span><span class="token plain"> ~/.hermes/state.db</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">User's WEC project codename is Bluefin-7, running on the OpenClaw box.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">/home/ubuntu/.hermes/state.db: SQLite 3.x database, ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">176K	/home/ubuntu/.hermes/state.db</span><br></div></code></pre></div></div>
<p>A plain-text <code>MEMORY.md</code> you can read and edit, backed by a SQLite <code>state.db</code> for
sessions. That's the persistent memory — and it just outlived a reboot.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>How the memory is stored — two files, two jobs</div><div class="admonitionContent_BuS1"><p>When you ask Hermes to remember something, it writes the fact to
<strong><code>~/.hermes/memories/MEMORY.md</code></strong> — plain Markdown you can open, read, and even edit by
hand. Separately, your <strong>full conversation history</strong> is kept in
<strong><code>~/.hermes/state.db</code></strong>, a <strong>SQLite</strong> database. Both live on disk, which is why a
reboot doesn't lose them.</p><p>The simplest way to see what the agent knows is just to read the Markdown:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> ~/.hermes/memories/MEMORY.md</span><br></div></code></pre></div></div><p>To look inside the database, note that SQLite's <em>engine</em> is built into Python, so Hermes
uses it without any extra install. The optional <code>sqlite3</code> <em>command-line tool</em> — for
browsing the DB by hand — isn't installed by default. If you want it:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">apt</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-y</span><span class="token plain"> sqlite3</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">sqlite3 ~/.hermes/state.db </span><span class="token string" style="color:#e3116c">'.tables'</span><br></div></code></pre></div></div><p><span class="zoomImage__wrap"><img alt="Output of .tables on state.db — sessions and messages tables alongside the messages_fts FTS5 family" src="https://development-wec.wiline.com/docs/assets/images/hermes-sqlite-tables-dbdc5016a3514137534038cac3f6298d.png" width="540" height="170" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p><p>You'll see <code>sessions</code> and <code>messages</code> tables holding the conversation, plus a family of
<code>messages_fts*</code> tables. Those are <strong>SQLite FTS5</strong> full-text indexes — including a
trigram variant for fuzzy matching — that let Hermes search its own past conversations:</p><div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output (from .schema)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">CREATE</span><span class="token plain"> VIRTUAL </span><span class="token keyword" style="color:#00009f">TABLE</span><span class="token plain"> messages_fts </span><span class="token keyword" style="color:#00009f">USING</span><span class="token plain"> fts5</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">CREATE</span><span class="token plain"> VIRTUAL </span><span class="token keyword" style="color:#00009f">TABLE</span><span class="token plain"> messages_fts_trigram </span><span class="token keyword" style="color:#00009f">USING</span><span class="token plain"> fts5</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">content</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> tokenize</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">'trigram'</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div><p>And each session is a row you can inspect — here are the two from this guide, the
second one created <em>after</em> the reboot:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">sqlite3 </span><span class="token parameter variable" style="color:#36acaa">-header</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-column</span><span class="token plain"> ~/.hermes/state.db </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"SELECT substr(id,1,15) AS id, model, message_count FROM sessions ORDER BY started_at;"</span><br></div></code></pre></div></div><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">id               model    message_count</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">---------------  -------  -------------</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">20260625_211227  gpt-5.5  8</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">20260625_212333  gpt-5.5  2</span><br></div></code></pre></div></div><p>You never <em>need</em> the CLI for Hermes to work — it's only for inspecting the database
yourself. Day to day, the human-readable <code>MEMORY.md</code> is all you have to look at.</p></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You now run an <strong>agent whose memory survives reboots</strong> — and you know exactly where that
memory lives on disk.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="bonus--read-the-conversation-the-agent-stored">Bonus — read the conversation the agent stored<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#bonus--read-the-conversation-the-agent-stored" class="hash-link" aria-label="Direct link to Bonus — read the conversation the agent stored" title="Direct link to Bonus — read the conversation the agent stored" translate="no">​</a></h2>
<p>The <code>messages</code> table holds the actual text of every turn. Each row has a <code>role</code> —
<code>user</code> (what you typed), <code>assistant</code> (Hermes's reply), or <code>tool</code> (a tool's result) — so
you can replay the whole exchange:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">sqlite3 </span><span class="token parameter variable" style="color:#36acaa">-header</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-column</span><span class="token plain"> ~/.hermes/state.db </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"SELECT substr(session_id,10,6) AS sess, role, substr(content,1,55) AS content</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">   FROM messages ORDER BY timestamp;"</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">sess    role       content</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">------  ---------  -------------------------------------------------------</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">211227  user       hello, can you hear me?</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">211227  assistant  Yes — I can hear you. How can I help?</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">211227  user       Please save to your long-term memory: my WEC project...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">211227  tool       {"success": true, "done": true, "target": "memory", ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">211227  assistant  Saved to long-term memory: your WEC project codename...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">212333  user       What's my WEC project codename, and which box does...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">212333  assistant  Your WEC project codename is Bluefin-7, and it runs...</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The messages table in state.db showing the full conversation — user, assistant, and tool rows across both sessions" src="https://development-wec.wiline.com/docs/assets/images/hermes-sqlite-messages-12ab80eeb7db27fbf02d5c73b075d553.png" width="659" height="294" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The interesting part is <em>how</em> the memory got written. The assistant didn't edit a file
directly — it emitted a <strong><code>memory</code> tool call</strong>, and that tool is what wrote
<code>MEMORY.md</code>. You can see the exact call recorded in the history:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">sqlite3 ~/.hermes/state.db </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token string" style="color:#e3116c">"SELECT tool_calls FROM messages WHERE tool_calls LIKE '%memory%' LIMIT 1;"</span><br></div></code></pre></div></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"function"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token property" style="color:#36acaa">"name"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"memory"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token property" style="color:#36acaa">"arguments"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  "</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">\"target\"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain">\"memory\"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain">\"operations\"</span><span class="token operator" style="color:#393A34">:</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">     </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">\"action\"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain">\"add\"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      \"content\"</span><span class="token operator" style="color:#393A34">:</span><span class="token plain">\"User's WEC project codename is Bluefin</span><span class="token number" style="color:#36acaa">-7</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> running on the OpenClaw box.\"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain">"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The recorded memory tool call in the messages table — the add operation that wrote the Bluefin-7 fact to MEMORY.md" src="https://development-wec.wiline.com/docs/assets/images/hermes-sqlite-memory-toolcall-53bc3762b14f9ca8e30715118341b792.png" width="897" height="362" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>So the full chain is: <strong>you type → the agent decides it's worth keeping → it calls the
<code>memory</code> tool with <code>action: add</code> → that writes the fact into <code>MEMORY.md</code></strong>, while the
whole conversation stays in <code>state.db</code>, full-text searchable via FTS5. Two stores, one
durable memory.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-can-you-do-with-it">What can you do with it?<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#what-can-you-do-with-it" class="hash-link" aria-label="Direct link to What can you do with it?" title="Direct link to What can you do with it?" translate="no">​</a></h2>
<p>A memory that survives reboots only matters because of what the agent <em>does</em> with it.
With Hermes running, the same install ships these capabilities (you saw them in the
<code>hermes doctor</code> tool list) — here's what they unlock day to day:</p>
<ul>
<li class=""><strong>Run real work on the box.</strong> The <code>terminal</code> and <code>code_execution</code> tools let it act,
not just chat — e.g. <em>"clone this repo, install deps, and run the tests,"</em> or <em>"check
why the disk is filling up."</em> It does it on the WEC Instance and reports back.</li>
<li class=""><strong>Drive it from your phone.</strong> A messaging gateway (Telegram, Discord, Slack, and
more) means you can hand it a task from anywhere — no SSH. <em>(Setup in Part 2.)</em></li>
<li class=""><strong>Put it on a schedule.</strong> The <code>cronjob</code> tool runs recurring jobs: a <strong>morning
briefing</strong>, a <strong>weekly disk-usage report</strong>, or <strong>watch a service and alert you when
it fails</strong> — delivered to your messaging channel. <em>(Part 3.)</em></li>
<li class=""><strong>Browse and search the web.</strong> With the browser and web-search tools it can pull live
information and act on real pages. <em>(These need provider API keys — see <code>hermes setup tools</code>.)</em></li>
<li class=""><strong>Grow reusable skills and delegate.</strong> It can build <strong>skills</strong> from what it learns and
hand subtasks to <strong>subagents</strong> — the "agent that grows with you" part.</li>
<li class=""><strong>Remember across all of it.</strong> Everything above is backed by the persistent memory you
just set up, so context carries from one task — and one day — to the next.</li>
</ul>
<p>The rest of this series turns each of these into a hands-on guide (see <em>What's next</em>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real">Troubleshooting (real)<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#troubleshooting-real" class="hash-link" aria-label="Direct link to Troubleshooting (real)" title="Direct link to Troubleshooting (real)" translate="no">​</a></h2>
<ul>
<li class="">
<p><strong>Quick Setup (Nous Portal) hangs at "Waiting for approval (polling)".</strong> The Portal
login needs an active subscription/credit. If you don't have one, <code>Ctrl+C</code> and use
<em>Full setup</em> with your own key instead (Step 3).</p>
</li>
<li class="">
<p><strong><code>doctor</code> shows "No API key found in ~/.hermes/.env".</strong> The default model points at
the Portal/OpenRouter. Run <code>hermes setup model</code>, pick your provider, and paste the
key — <code>doctor</code> then reports <em>"API key or custom endpoint configured."</em></p>
</li>
<li class="">
<p><strong>Deprecated <code>.env</code> warning on gateway start</strong> (e.g. <code>MESSAGING_CWD</code>). Harmless;
follow the hint to move the setting into <code>config.yaml</code> when convenient.</p>
</li>
<li class="">
<p><strong>Repeating <code>WARNING [Telegram] …</code> in the gateway logs.</strong> If you followed the earlier
OpenClaw parts and added a Telegram channel, Hermes's setup detected OpenClaw and
<strong>imported those messaging settings</strong> into <code>~/.hermes/.env</code>. Because we skipped
finishing messaging here, the gateway has a token but no complete config, so it keeps
retrying and logging this warning. (A fresh box with no OpenClaw Telegram setup won't
see it.) Remove the stray entries and restart:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'/^TELEGRAM_ALLOWED_USERS=/d; /^TELEGRAM_BOT_TOKEN=/d'</span><span class="token plain"> ~/.hermes/.env</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">systemctl </span><span class="token parameter variable" style="color:#36acaa">--user</span><span class="token plain"> restart hermes-gateway</span><br></div></code></pre></div></div>
<p>If that token was ever exposed, revoke it in Telegram's <strong>@BotFather</strong> and issue a new
one.</p>
</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Agent with persistent memory</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Now that Hermes is running with durable memory, the rest of the series turns each
capability above into a hands-on guide:</p>
<ul>
<li class=""><strong>Part 2 — Drive Hermes from Telegram.</strong> Finish migrating the Telegram channel Hermes
already imported from OpenClaw (<code>hermes setup gateway</code>, allowlists, verify the bot
responds), so you can hand it tasks from your phone.</li>
<li class=""><strong>Part 3 — Put it on a schedule.</strong> Use the cron tool for a morning briefing, a weekly
report, and a service-health alert delivered to your channel.</li>
<li class=""><strong>Part 4 — Custom skills &amp; subagents.</strong> Teach it reusable skills and delegate work.</li>
</ul>
<p>Links will appear here as each part ships.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>hermes</category>
            <category>agents</category>
            <category>memory</category>
        </item>
        <item>
            <title><![CDATA[Make OpenClaw private with a NetBird mesh VPN]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/</guid>
            <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Put OpenClaw on a private mesh with NetBird, repoint your hostname at the mesh IP, and close the public ports — so the same chat URL works only for your devices. Real commands and gotchas from a live run.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><img class="tutorialHero__docker" src="https://development-wec.wiline.com/docs/assets/images/netbird-dark-0d2ba9c783792904def84b7e687b8f95.png" alt="NetBird"></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Deploy your own AI assistant</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Real HTTPS + auth</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Chat from Telegram</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">4</span><span class="skillTracker__skill" data-state="current">Private mesh access</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">Run it on WEC models</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Chat from WhatsApp</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>In <a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Article 2</a> we put Caddy in front of
OpenClaw for HTTPS, and in <a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Article 3</a>
we added a Telegram channel. The gateway works — but Caddy is still listening on
<code>0.0.0.0</code>, reachable by anything that can route to the box. This is the capstone
of the series: we join the server and your laptop to a <strong>NetBird</strong> mesh,
repoint <code>openclaw.local</code> at the mesh IP, and <strong>close the public ports</strong>. The same
<code>https://openclaw.local/chat</code> URL keeps working — but only for your devices.
Every command and error below is from the actual run.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues from Articles 1–3 on the same WEC Instance — <strong>Ubuntu</strong>, OpenClaw behind
<strong>Caddy 2</strong>. We use <strong>managed NetBird</strong> (the free tier at
<a href="https://app.netbird.io/" target="_blank" rel="noopener noreferrer" class="">app.netbird.io</a>) with agent <strong>0.73.2</strong> on both the
server (Linux, kernel WireGuard) and the client (macOS, userspace). NetBird is
<a href="https://www.wireguard.com/" target="_blank" rel="noopener noreferrer" class="">WireGuard</a>-based and open source; you can also
self-host the control plane, which is a bigger topic for another day.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-netbird-works-and-what-youll-build">How NetBird works (and what you'll build)<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#how-netbird-works-and-what-youll-build" class="hash-link" aria-label="Direct link to How NetBird works (and what you'll build)" title="Direct link to How NetBird works (and what you'll build)" translate="no">​</a></h2>
<p>NetBird has two parts, and the distinction is the whole point:</p>
<ol>
<li class=""><strong>The control plane</strong> (NetBird's managed <em>management</em> + <em>signal</em> service) — this is the only thing with a public presence. Every device that wants to join <strong>authenticates to it</strong> (with your setup key or SSO login), and it brokers the exchange of each peer's <a href="https://www.wireguard.com/" target="_blank" rel="noopener noreferrer" class="">WireGuard</a> public key.</li>
<li class=""><strong>The data plane</strong> — once authenticated, peers talk <strong>directly to each other</strong>, end-to-end encrypted over WireGuard. Your actual traffic never flows through a public port you manage; the control plane just did the introductions.</li>
</ol>
<p>So you're building a mesh where <strong>Peer A</strong> (your laptop) reaches <strong>Peer B</strong> (the OpenClaw box) by its private <code>100.x</code> address — and <code>openclaw.local</code> points there, with Caddy bound to that interface only. The public/LAN side has nothing listening.</p>
<p>The key idea for later: <strong>the mesh isn't limited to two peers.</strong> Authenticate once, and every new service — Ollama, a VDI, WordPress — joins as another peer with its own <code>100.x</code> address, reachable privately with <strong>no public ports to open</strong>. You set up the auth model once; you add services forever.</p>
<!-- -->
<p>The public internet only ever reaches <strong>NetBird's control plane</strong> — and that just
<em>authenticates</em> peers, it never carries your app traffic. Your services aren't
"firewalled off" so much as <strong>invisible</strong>: OpenClaw has no public IP, so there's
simply <strong>no route</strong> to it from the internet. The only way in is to be an
authenticated peer on the mesh.</p>
<p>In this tutorial we wire up the first two peers — <strong>your laptop</strong> and the
<strong>OpenClaw box</strong>. The other services make the point: once auth is set up,
<code>Ollama</code>, a <code>VDI</code>, <code>WordPress</code> each just join the same mesh as another <code>100.x</code>
peer — no public ports, no per-service exposure.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="stand-up-a-netbird-control-plane">Stand up a NetBird control plane<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#stand-up-a-netbird-control-plane" class="hash-link" aria-label="Direct link to Stand up a NetBird control plane" title="Direct link to Stand up a NetBird control plane" translate="no">​</a></h2>
<p>Every NetBird mesh has two roles: <strong>peers</strong> — your machines, i.e. the OpenClaw box
from Parts 1–3 and your laptop — and a <strong>control plane</strong> that authenticates those
peers and issues their join keys. You already have the peer (your OpenClaw box);
now you need a control plane for it to join. Two ways to get one:</p>
<ul>
<li class=""><strong>Option A — self-host it on WiLine (recommended).</strong> Deploy WEC's <strong>NetBird Marketplace template</strong>: a <strong>dedicated WEC Instance</strong> that runs your <em>own</em> NetBird control plane — dashboard, trusted certificate, zero shell. Now your <em>entire</em> private network lives on WiLine — the OpenClaw box <strong>and</strong> its control plane — with no third-party dependency.</li>
<li class=""><strong>Option B — managed NetBird (no extra box).</strong> Don't want to run your own control plane? Use the free tier at <a href="https://app.netbird.io/" target="_blank" rel="noopener noreferrer" class="">app.netbird.io</a> — sign in and you're done; the control plane is NetBird's cloud. Then skip to <a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#now-enroll-your-machines" class="">Enroll your machines</a>.</li>
</ul>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Two boxes, on purpose — and your OpenClaw box is untouched</div><div class="admonitionContent_BuS1"><p>With <strong>Option A</strong> you'll run <strong>two instances</strong>: your <strong>OpenClaw box</strong> (Parts 1–3,
unchanged) and a <strong>second, dedicated control-plane box</strong> (this template). That's the
correct topology — a control plane should be its own machine, never sharing with a
workload. With <strong>Option B</strong> there's only your one OpenClaw box. <strong>Either way you
never rebuild the OpenClaw box</strong> — in <a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#now-enroll-your-machines" class="">Enroll your machines</a>
you just install the NetBird agent on it and it joins the mesh as a peer.</p></div></div>
<p>The steps below set up <strong>Option A</strong>. WEC runs the install; you give it an email and a
public IP, and a few minutes later you have your own NetBird dashboard on
<code>https://&lt;your-ip&gt;.apps.wiline.cloud</code> with a valid certificate.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-get-a-public-elastic-ip">1. Get a public (Elastic) IP<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#1-get-a-public-elastic-ip" class="hash-link" aria-label="Direct link to 1. Get a public (Elastic) IP" title="Direct link to 1. Get a public (Elastic) IP" translate="no">​</a></h3>
<p>The dashboard needs a public address. Under <strong>Networks → Elastic IPs</strong>, allocate a
new IP or pick a free one, and note its value (ours: <code>108.60.112.165</code>). It must be
in the <strong>VPC and zone you'll deploy into</strong>. Full steps:
<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/networks/elastic_ip/">Configure Elastic IPs</a>.</p>
<p><span class="zoomImage__wrap"><img alt="An available Elastic IP in Networks → Elastic IPs" src="https://development-wec.wiline.com/docs/assets/images/netbird-eip-available-b4ec56b4c4946226b7c9e199bd540f61.png" width="2793" height="1305" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-deploy-a-vm-with-the-template">2. Deploy a VM with the template<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#2-deploy-a-vm-with-the-template" class="hash-link" aria-label="Direct link to 2. Deploy a VM with the template" title="Direct link to 2. Deploy a VM with the template" translate="no">​</a></h3>
<p>This new instance becomes your <strong>NetBird control plane</strong> — a separate box from your
OpenClaw instance, which stays untouched. Open <strong>Deploy → Deploy a VM</strong> (full walkthrough:
<a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/">Deploy a Virtual Machine</a>).
The choices that matter here:</p>
<ul>
<li class=""><strong>Network</strong> — pick the network in the <strong>same VPC/zone as your Elastic IP</strong> (we used <code>Local Subnet</code> in <code>Princeton-VPC</code>), so the IP can attach afterward.</li>
<li class=""><strong>Template → Marketplace → Ubuntu 24.04 LTS Netbird.</strong></li>
<li class=""><strong>Template Configuration</strong> — your <strong>Let's Encrypt email</strong> and the <strong>Elastic IP from step 1</strong> as the external address.</li>
</ul>
<p><span class="zoomImage__wrap"><img alt="Template Configuration — Let&amp;#39;s Encrypt email and the Elastic IP" src="https://development-wec.wiline.com/docs/assets/images/netbird-template-config-e03939727bf27b7f6ce4ceb4fc5acdf1.png" width="1968" height="1240" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Finish the wizard and deploy.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-attach-the-elastic-ip-to-the-new-instance">3. Attach the Elastic IP to the new instance<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#3-attach-the-elastic-ip-to-the-new-instance" class="hash-link" aria-label="Direct link to 3. Attach the Elastic IP to the new instance" title="Direct link to 3. Attach the Elastic IP to the new instance" translate="no">​</a></h3>
<p>The IP isn't bound at deploy time — you attach it once the VM is up. Go to
<strong>Networks → Elastic IPs → your IP → ⋯ → Allocate</strong>, and select your new instance
(<code>netbird-tutorial</code>). Only VMs in the <strong>same VPC</strong> appear in the list.</p>
<p><span class="zoomImage__wrap"><img alt="Allocating the Elastic IP to the new VM" src="https://development-wec.wiline.com/docs/assets/images/netbird-eip-allocate-620e7b57084f55e9e8ca3323494cda6d.png" width="1834" height="1070" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-wait-for-first-boot-then-verify-its-up">4. Wait for first boot, then verify it's up<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#4-wait-for-first-boot-then-verify-its-up" class="hash-link" aria-label="Direct link to 4. Wait for first boot, then verify it's up" title="Direct link to 4. Wait for first boot, then verify it's up" translate="no">​</a></h3>
<p>The instance shows <strong>Running</strong> in <strong>Compute → Instances</strong> within a minute — but the
template keeps working for a few minutes after that, pulling and starting the
NetBird containers and bringing up the dashboard. Don't expect the URL to answer
immediately.</p>
<p>Check from your <strong>laptop</strong> over HTTPS (not <code>ping</code> — ICMP is firewalled off, so a
failed ping doesn't mean it's down):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-skS</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">10</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> /dev/null </span><span class="token parameter variable" style="color:#36acaa">-w</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"%{http_code}</span><span class="token string entity" style="color:#36acaa">\n</span><span class="token string" style="color:#e3116c">"</span><span class="token plain"> https://</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">your-ip</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">.apps.wiline.cloud</span><br></div></code></pre></div></div>
<ul>
<li class=""><code>000</code> / connection refused → still provisioning; wait a minute and retry.</li>
<li class=""><code>301</code> then <code>200</code> → it's up (HTTP redirects to HTTPS).</li>
</ul>
<p>To watch it directly, SSH in (<code>ssh ubuntu@&lt;your-ip&gt;</code>) and confirm the containers are
running and the ports are bound:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">cloud-init status</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">ps</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">':(80|443)'</span><br></div></code></pre></div></div>
<p>It's ready once <strong><code>netbird-dashboard</code>, <code>netbird-traefik</code>, and <code>netbird-server</code></strong> show
<strong>Up</strong> and <strong>80/443</strong> are listening.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-open-the-dashboard-and-create-your-account">5. Open the dashboard and create your account<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#5-open-the-dashboard-and-create-your-account" class="hash-link" aria-label="Direct link to 5. Open the dashboard and create your account" title="Direct link to 5. Open the dashboard and create your account" translate="no">​</a></h3>
<p>Open <strong><code>https://&lt;your-ip&gt;.apps.wiline.cloud</code></strong> — click through the certificate
warning if you get one (same self-signed-cert click-through as
<a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Article 2</a>).</p>
<p>This is <strong>your own</strong> NetBird control plane, running on your WEC Instance. On the
first visit it asks you to <strong>create the admin account</strong> — enter a <strong>name, email,
and password</strong>, click <strong>Create</strong>, then <strong>sign in</strong> with those same credentials.
(That setup screen appears only once; afterwards the dashboard shows a normal
<strong>Sign in</strong> page.)</p>
<p>You land on the NetBird dashboard — your private control plane on your own domain:</p>
<p><span class="zoomImage__wrap"><img alt="The self-hosted NetBird dashboard" src="https://development-wec.wiline.com/docs/assets/images/netbird-dashboard-c98eca329c445df75fee98ca55f230e9.png" width="2850" height="1646" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Click <strong>Add Peer</strong> and NetBird hands you the enrollment command for <em>your</em> server —
note the <code>--management-url</code> pointing at your own domain:</p>
<p><span class="zoomImage__wrap"><img alt="Add Peer on the self-hosted dashboard — note the --management-url flag" src="https://development-wec.wiline.com/docs/assets/images/netbird-add-peer-09fd78007bcc0279cae91217c1786221.png" width="2827" height="1599" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://pkgs.netbird.io/install.sh </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">netbird up --management-url https://</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">your-ip</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">.apps.wiline.cloud</span><br></div></code></pre></div></div>
<p>(That second command opens a browser SSO login. For a <strong>headless</strong> server, add
<code>--setup-key &lt;KEY&gt;</code> from your dashboard's <strong>Setup Keys</strong> page instead — same as the
managed flow, just with <code>--management-url</code> added.)</p>
<p>This is the dashboard where you create the setup keys that enroll machines — which
is exactly what we do next.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="now-enroll-your-machines">Now enroll your machines<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#now-enroll-your-machines" class="hash-link" aria-label="Direct link to Now enroll your machines" title="Direct link to Now enroll your machines" translate="no">​</a></h2>
<p>You have a control plane (your own template dashboard, or managed NetBird). Now put
the two machines on the mesh — the <strong>OpenClaw box</strong> from Parts 1–3 and your
<strong>laptop</strong> — and then close the box's public ports. These steps are the same no
matter which control plane you picked.</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Where your setup key comes from</div><div class="admonitionContent_BuS1"><p>The commands below use <strong>managed NetBird</strong> (<code>app.netbird.io</code>) as the example. If you
deployed the <strong>WEC template</strong> instead, it's identical except: create the setup key
in <strong>your own</strong> dashboard (<code>https://&lt;your-ip&gt;.apps.wiline.cloud</code>) and add
<strong><code>--management-url https://&lt;your-ip&gt;.apps.wiline.cloud</code></strong> to <code>netbird up</code>, so the
agent points at <em>your</em> server instead of the managed cloud.</p></div></div>
<p><strong>Prerequisites:</strong> OpenClaw running behind Caddy from
<a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Articles 1–3</a> (reachable at
<code>https://openclaw.local</code>), a control plane from the step above, and shell access to
the box plus admin on your laptop.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--install-the-netbird-agent-on-the-openclaw-box">Step 1 — Install the NetBird agent on the OpenClaw box<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-1--install-the-netbird-agent-on-the-openclaw-box" class="hash-link" aria-label="Direct link to Step 1 — Install the NetBird agent on the OpenClaw box" title="Direct link to Step 1 — Install the NetBird agent on the OpenClaw box" translate="no">​</a></h2>
<p>This is the box from Parts 1–3 (a WEC Instance) — we're enrolling it as a <strong>peer</strong>,
which is separate from any control-plane server. NetBird ships a one-line installer:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://pkgs.netbird.io/install.sh </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><br></div></code></pre></div></div>
<p><strong>Gotcha (real one):</strong> on Ubuntu the install triggers the <code>needrestart</code> dialog —
a blue <em>"Which services should be restarted?"</em> box. <code>Tab</code> between buttons can get
swallowed by some SSH/terminal setups; use <strong>arrow keys</strong> to move, <strong>Space</strong> to
toggle, <strong>Enter</strong> to confirm (<code>&lt;Ok&gt;</code> is the default). The package is already in
place by the time this box appears, so even if you <code>Ctrl+C</code> out of it, the agent
is installed — re-running the installer just tells you so:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">NetBird seems to be installed already, please remove it before proceeding</span><br></div></code></pre></div></div>
<p>(If you re-run the installer after this tutorial, you'll instead see
<code>NetBird service is running, please stop it before proceeding</code> — the agent's
already up, so there's nothing to reinstall.)</p>
<p>Confirm the version:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">netbird version</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 0.73.2</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--create-a-setup-key-and-join-the-openclaw-box">Step 2 — Create a setup key and join the OpenClaw box<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-2--create-a-setup-key-and-join-the-openclaw-box" class="hash-link" aria-label="Direct link to Step 2 — Create a setup key and join the OpenClaw box" title="Direct link to Step 2 — Create a setup key and join the OpenClaw box" translate="no">​</a></h2>
<p>A <strong>setup key</strong> is the token that enrolls a device into your mesh — ideal for a
headless server (no browser SSO needed).</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>First time in the dashboard? Skip the detours</div><div class="admonitionContent_BuS1"><p>A few things will try to send you the wrong way on a fresh account — here's the
straight line:</p><ul>
<li class=""><strong>No account yet?</strong> Create one first (sign in with Google/GitHub/email). You can't generate a key until you're in.</li>
<li class=""><strong>The onboarding wizard</strong> ("Add your first resource" — <em>Single IP / Entire Subnet / Domain</em>) pops up immediately. <strong>Skip it.</strong> Resources are for exposing subnets/domains; we just need two plain peers, so it's not needed.</li>
<li class=""><strong>The orange "Add Peer" button</strong> opens the <strong>SSO install flow</strong> (download the app, sign up with email, connect from the tray). That's meant for a desktop with a browser — <strong>not</strong> a headless server. Don't follow it.</li>
<li class="">Instead, go straight to the <strong>Setup Keys</strong> page: <strong><code>https://app.netbird.io/setup-keys</code></strong>.</li>
</ul></div></div>
<ol>
<li class="">Open <strong><code>https://app.netbird.io/setup-keys</code></strong>.</li>
<li class=""><strong>Create Setup Key</strong> → name <code>openclaw-tutorial</code>, toggle <strong>Reusable</strong> (so it works for the laptop too), defaults otherwise.</li>
<li class="">Copy the key.</li>
</ol>
<p><span class="zoomImage__wrap"><img alt="Creating a reusable setup key in the NetBird dashboard" src="https://development-wec.wiline.com/docs/assets/images/netbird-setup-key-ade9af748bded2998a18890a1ebde93b.png" width="1898" height="950" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Connect the VM (replace with your key):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> netbird up --setup-key </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">YOUR_SETUP_KEY</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Treat the setup key as a secret</div><div class="admonitionContent_BuS1"><p>Anyone with a reusable key can add devices to your network. Don't paste it into
chats or commits, and <strong>delete it</strong> from the dashboard once your devices are
enrolled (the existing peers stay connected).</p></div></div>
<p>Check the connection:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">netbird status</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="netbird status on the VM — Management &amp;amp; Signal Connected, mesh IP 100.87.239.229, Peers count 0/0" src="https://development-wec.wiline.com/docs/assets/images/netbird-vm-status-8e8088e406d7392203f1eb5da63b4258.png" width="731" height="374" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>That <strong><code>NetBird IP</code> (<code>100.87.239.229</code>)</strong> is the address everything below points
at. <code>Peers count: 0/0</code> is expected — nothing else has joined yet.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--join-your-laptop">Step 3 — Join your laptop<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-3--join-your-laptop" class="hash-link" aria-label="Direct link to Step 3 — Join your laptop" title="Direct link to Step 3 — Join your laptop" translate="no">​</a></h2>
<p>Install the NetBird client on whatever you're using, then connect with the
<strong>same reusable setup key</strong>. The connect command (<code>netbird up --setup-key …</code>) is
identical on every OS — only the install differs.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>We ran this on macOS</div><div class="admonitionContent_BuS1"><p>The captured output below is from a Mac. The Windows and Linux tabs give the
equivalent install; the <code>netbird up</code>, <code>netbird status</code>, and ping steps are the
same everywhere (Windows uses <code>ping -n</code> instead of <code>-c</code>).</p></div></div>
<div class="theme-tabs-container tabs-container tabList_zI5C"><ul role="tablist" aria-orientation="horizontal" class="tabs"><li role="tab" tabindex="0" aria-selected="true" class="tabs__item tabItem_yeRP tabs__item--active">macOS</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_yeRP">Windows</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_yeRP">Linux</li></ul><div class="margin-top--md"><div role="tabpanel" class="tabItem_yrzq"><p>Install via Homebrew, then start the service and connect:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">brew </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"> netbirdio/tap/netbird</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> netbird </span><span class="token function" style="color:#d73a49">service</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">install</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> netbird </span><span class="token function" style="color:#d73a49">service</span><span class="token plain"> start</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">netbird up --setup-key </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">YOUR_SETUP_KEY</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div><p>You may see a one-off gRPC warning as the daemon's socket comes up a beat after
the client calls it — it ends in <code>Connected</code>, so it's harmless:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">WARNING: ... dial unix /var/run/netbird.sock: connect: no such file or directory</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Connected</span><br></div></code></pre></div></div></div><div role="tabpanel" class="tabItem_yrzq" hidden=""><p>Download and run the <strong>NetBird Windows installer</strong> from
<a href="https://app.netbird.io/" target="_blank" rel="noopener noreferrer" class="">app.netbird.io</a> (Add Peer → Windows) or
<a href="https://pkgs.netbird.io/" target="_blank" rel="noopener noreferrer" class="">pkgs.netbird.io</a>. It installs a system-tray app and the
<code>netbird</code> CLI. Then, in <strong>PowerShell as Administrator</strong>, connect with your key:</p><div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">netbird up --setup-key &lt;YOUR_SETUP_KEY&gt;</span><br></div></code></pre></div></div><p>(You can also click <strong>Connect</strong> from the tray icon and authenticate via SSO, but
the setup key is the same one-shot flow as the other platforms.)</p></div><div role="tabpanel" class="tabItem_yrzq" hidden=""><p>Same one-line installer as the VM, then connect:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://pkgs.netbird.io/install.sh </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> netbird up --setup-key </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">YOUR_SETUP_KEY</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div></div></div></div>
<p>Verify the two nodes see each other, and that the tunnel carries traffic:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">netbird status</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">ping</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-c</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">100.87</span><span class="token plain">.239.229   </span><span class="token comment" style="color:#999988;font-style:italic"># Windows: ping -n 3 100.87.239.229</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Mac netbird status showing Peers count 1/1 Connected, plus a successful ping to the VM&amp;#39;s mesh IP" src="https://development-wec.wiline.com/docs/assets/images/netbird-mac-status-857d291924930e23af4e6550b1845ec7.png" width="609" height="475" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><code>Peers count: 1/1</code> — that peer is the VM. In the dashboard, both machines now
show up under <strong>Peers</strong> (because we enrolled them with a setup key rather than an
SSO login, they appear under <strong>Servers</strong> rather than <strong>User Devices</strong>):</p>
<p><span class="zoomImage__wrap"><img alt="The VM and laptop both connected as peers in the NetBird dashboard" src="https://development-wec.wiline.com/docs/assets/images/netbird-peers-a54440db77e8715febacc8d410c24da5.png" width="1895" height="951" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>Why ~200 ms?</div><div class="admonitionContent_BuS1"><p>This path is going through a NetBird <strong>relay</strong> (note <code>Interface type: Userspace</code>
on the Mac). It's fine for a web UI, and NetBird will often upgrade to a direct
peer-to-peer link when the networks allow. Latency to a relayed peer is not a
red flag.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--repoint-openclawlocal-at-the-mesh-ip">Step 4 — Repoint <code>openclaw.local</code> at the mesh IP<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-4--repoint-openclawlocal-at-the-mesh-ip" class="hash-link" aria-label="Direct link to step-4--repoint-openclawlocal-at-the-mesh-ip" title="Direct link to step-4--repoint-openclawlocal-at-the-mesh-ip" translate="no">​</a></h2>
<p>From <a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Article 2</a>, your hosts file maps
<code>openclaw.local</code> to the VM's LAN IP (<code>10.80.4.212</code>). Swap it for the mesh IP
(<code>100.87.239.229</code>). The file path and edit command differ by OS:</p>
<div class="theme-tabs-container tabs-container tabList_zI5C"><ul role="tablist" aria-orientation="horizontal" class="tabs"><li role="tab" tabindex="0" aria-selected="true" class="tabs__item tabItem_yeRP tabs__item--active">macOS</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_yeRP">Linux</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_yeRP">Windows</li></ul><div class="margin-top--md"><div role="tabpanel" class="tabItem_yrzq"><p><code>sed</code> on macOS needs the empty <code>-i ''</code>:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> openclaw /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 10.80.4.212 openclaw.local</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">''</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^10\.80\.4\.212 openclaw\.local/100.87.239.229 openclaw.local/'</span><span class="token plain"> /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> openclaw /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 100.87.239.229 openclaw.local</span><br></div></code></pre></div></div></div><div role="tabpanel" class="tabItem_yrzq" hidden=""><p>GNU <code>sed</code> takes <code>-i</code> with no argument:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> openclaw /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sed</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-i</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">'s/^10\.80\.4\.212 openclaw\.local/100.87.239.229 openclaw.local/'</span><span class="token plain"> /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> openclaw /etc/hosts</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 100.87.239.229 openclaw.local</span><br></div></code></pre></div></div></div><div role="tabpanel" class="tabItem_yrzq" hidden=""><p>The hosts file is at <code>C:\Windows\System32\drivers\etc\hosts</code> and needs admin.
In <strong>PowerShell as Administrator</strong>:</p><div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">$h = "$env:WINDIR\System32\drivers\etc\hosts"</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">(Get-Content $h) -replace '^10\.80\.4\.212 openclaw\.local','100.87.239.229 openclaw.local' | Set-Content $h</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">Select-String openclaw $h</span><br></div></code></pre></div></div><p>(Or open that file in <strong>Notepad run as Administrator</strong> and change the IP by hand.)</p></div></div></div>
<p>Test over the mesh — <em>before</em> touching any ports:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-kI</span><span class="token plain"> https://openclaw.local/</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">HTTP/2 200</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">via: 1.1 Caddy</span><br></div></code></pre></div></div>
<p><code>via: 1.1 Caddy</code> over the mesh IP — the chat now flows through NetBird. Load
<code>https://openclaw.local/chat?session=main</code> in the browser to confirm it behaves
exactly as before. The TLS cert still matches because the hostname is unchanged;
only the IP behind it moved.</p>
<p><span class="zoomImage__wrap"><img alt="The OpenClaw chat loading over the mesh at https://openclaw.local" src="https://development-wec.wiline.com/docs/assets/images/netbird-chat-mesh-409321edd81ea8d59656891246bc0558.png" width="1900" height="993" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--close-the-public-ports">Step 5 — Close the public ports<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-5--close-the-public-ports" class="hash-link" aria-label="Direct link to Step 5 — Close the public ports" title="Direct link to Step 5 — Close the public ports" translate="no">​</a></h2>
<p>Here's the state we're fixing. On the VM:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">':(80|443)'</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockTitle_OeMC">Output — listening on 0.0.0.0 (every interface)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">LISTEN 0 4096  0.0.0.0:443  ...  docker-proxy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">LISTEN 0 4096  0.0.0.0:80   ...  docker-proxy</span><br></div></code></pre></div></div>
<p>Caddy listens on <strong><code>0.0.0.0</code></strong> — every interface, including the LAN (and any NAT
in front of it).</p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>The ufw trap</div><div class="admonitionContent_BuS1"><p>Your first instinct is <code>ufw deny 443</code>. <strong>It won't work.</strong> Caddy's ports are
published by <strong>Docker</strong>, which writes its own iptables rules that bypass ufw's
<code>INPUT</code> chain. You'd add ufw rules, feel safe, and 443 would stay wide open. On
this box <code>ufw</code> was even <code>inactive</code> — and enabling it would have changed nothing
for the Docker-published ports.</p></div></div>
<p>The reliable fix is to <strong>bind the published ports to the mesh IP</strong> instead of
<code>0.0.0.0</code>, so Caddy only ever listens on <code>wt0</code>. Edit the Caddy service in
<code>docker-compose.override.yml</code> (the override from Article 2):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> /home/ubuntu/openclaw</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">cp</span><span class="token plain"> docker-compose.override.yml docker-compose.override.yml.bak</span><br></div></code></pre></div></div>
<p>Change the ports block from:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">ports</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"80:80"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"443:443"</span><br></div></code></pre></div></div>
<p>to bind to the mesh IP:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">ports</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"100.87.239.229:80:80"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"100.87.239.229:443:443"</span><br></div></code></pre></div></div>
<p>Recreate just Caddy and re-check:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> caddy</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> ss </span><span class="token parameter variable" style="color:#36acaa">-tlnp</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-E</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">':(80|443)'</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="ss after the change — Caddy now listening only on the mesh IP 100.87.239.229, no longer on 0.0.0.0" src="https://development-wec.wiline.com/docs/assets/images/netbird-ss-after-0172e6fc62bc667a141b1f6528526e73.png" width="745" height="103" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The ports are gone from <code>0.0.0.0</code> — they live on the mesh interface only.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Startup ordering</div><div class="admonitionContent_BuS1"><p>Because the bind targets the mesh IP, <strong>NetBird must be up before Caddy starts</strong>
(e.g. after a reboot, <code>wt0</code> needs its IP before Docker binds to it). The
<code>restart: unless-stopped</code> policy already covers this — Caddy retries until the
interface exists — but it's worth knowing if you ever see Caddy crash-looping
right after boot.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--prove-the-lockdown">Step 6 — Prove the lockdown<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#step-6--prove-the-lockdown" class="hash-link" aria-label="Direct link to Step 6 — Prove the lockdown" title="Direct link to Step 6 — Prove the lockdown" translate="no">​</a></h2>
<p>Both directions, from the actual run.</p>
<p><strong>Still reachable over the mesh</strong> (laptop):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-kI</span><span class="token plain"> https://openclaw.local/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># HTTP/2 200 ... via: 1.1 Caddy</span><br></div></code></pre></div></div>
<p><strong>No longer reachable on the LAN</strong> (VM, hitting its own LAN IP):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-kI</span><span class="token plain"> --connect-timeout </span><span class="token number" style="color:#36acaa">5</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--resolve</span><span class="token plain"> openclaw.local:443:10.80.4.212 https://openclaw.local/</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="On the VM, curl to the old LAN IP now returns Connection refused" src="https://development-wec.wiline.com/docs/assets/images/netbird-refused-0944b25666489470ae7b961e2d6243c8.png" width="617" height="55" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>That's the result: <strong>same URL, reachable only through your mesh, refused
everywhere else.</strong></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>take any self-hosted service off the public internet</strong> without breaking how you
use it.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="cleanup">Cleanup<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#cleanup" class="hash-link" aria-label="Direct link to Cleanup" title="Direct link to Cleanup" translate="no">​</a></h2>
<p>Delete the reusable setup key now that both devices are enrolled —
<strong>Setup Keys → delete</strong> in the dashboard. Your peers stay connected; you're just
closing the door you used to add them. To remove a device later, delete it under
<strong>Peers → User Devices</strong> and run <code>netbird down</code> on that machine.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real">Troubleshooting (real)<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#troubleshooting-real" class="hash-link" aria-label="Direct link to Troubleshooting (real)" title="Direct link to Troubleshooting (real)" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>needrestart</code> dialog won't take <code>Tab</code>.</strong> Use arrow keys / Space / Enter. The package is already installed by the time the box shows.</li>
<li class=""><strong>gRPC <code>netbird.sock</code> warning on first <code>up</code>.</strong> The daemon's socket lags the client by a beat. If it ends in <code>Connected</code>, ignore it.</li>
<li class=""><strong><code>ufw</code> rules don't block the gateway.</strong> Docker-published ports bypass ufw — bind the port to a specific IP (as above) or use Docker's own <code>DOCKER-USER</code> chain.</li>
<li class=""><strong>Caddy crash-loops after reboot.</strong> It started before <code>wt0</code> had the mesh IP. <code>restart: unless-stopped</code> recovers it; or order Docker after <code>netbird.service</code>.</li>
<li class=""><strong>High ping to a peer.</strong> You're relayed, not direct — functional, just slower. Check <code>Interface type</code> and NetBird's peer detail for the connection type.</li>
</ul>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Private mesh access</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>That closes the <strong>Self-hosting OpenClaw</strong> series:</p>
<ol>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Deploy OpenClaw on a WEC Instance via Docker Compose</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Secure OpenClaw with a Caddy reverse proxy + HTTPS</a></li>
<li class=""><a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Add a Telegram channel to OpenClaw</a></li>
<li class=""><strong>Make OpenClaw private with a NetBird mesh VPN</strong> ← you are here</li>
</ol>
<p>You now have a self-hosted agent that's encrypted in transit, paired to your
devices, reachable from your phone, and invisible to the public internet — all on
one small WEC Instance.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="continue-the-journey">Continue the journey<a href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/#continue-the-journey" class="hash-link" aria-label="Direct link to Continue the journey" title="Direct link to Continue the journey" translate="no">​</a></h3>
<p>Ready for an agent that <em>remembers</em>? Start the <strong>Self-hosting Hermes</strong> series —
<strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Self-host the Hermes Agent with persistent memory</a></strong> —
deployed on this same WEC Instance, with context that survives restarts.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>openclaw</category>
            <category>netbird</category>
            <category>vpn</category>
            <category>mesh</category>
            <category>security</category>
        </item>
        <item>
            <title><![CDATA[Add a Telegram channel to OpenClaw]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/</guid>
            <pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Talk to your self-hosted OpenClaw agent from your phone. Create a Telegram bot, connect it, clear the pairing gate, and chat — captured from a real run.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#26A5E4" aria-label="Telegram" class="tutorialHero__docker"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Deploy your own AI assistant</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Real HTTPS + auth</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">3</span><span class="skillTracker__skill" data-state="current">Chat from Telegram</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Private mesh access</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">Run it on WEC models</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Chat from WhatsApp</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>You've <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">deployed OpenClaw</a> and
<a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">secured it behind HTTPS</a>. Now make it
<em>usable</em> — talk to your agent from your phone via Telegram. We create a bot,
connect it, clear OpenClaw's pairing gate, and get a real reply. Every command and
gotcha below is from an actual run.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues from Parts 1–2 on the same WEC Instance — OpenClaw <strong>2026.6.8</strong>. Telegram
uses <strong>polling</strong> (the gateway polls Telegram's API), so no public webhook or domain
is required — this works on a private box.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<p>Your phone talks to a Telegram bot; the OpenClaw gateway polls Telegram for
messages, runs them through your model, and replies — no inbound ports needed.</p>
<!-- -->
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class=""><strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Part 1</a></strong> + <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Part 2</a></strong> done — OpenClaw running in <code>~/openclaw</code></li>
<li class="">A <strong>Telegram account</strong> and a <strong>logged-in Telegram client</strong> — the phone/desktop app, or <code>web.telegram.org</code>. (You can't create a bot from the public <code>t.me</code> web page; see <a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#troubleshooting-real-errors" class="">Troubleshooting</a>.)</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--create-a-telegram-bot">Step 1 — Create a Telegram bot<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-1--create-a-telegram-bot" class="hash-link" aria-label="Direct link to Step 1 — Create a Telegram bot" title="Direct link to Step 1 — Create a Telegram bot" translate="no">​</a></h2>
<p>In the Telegram app, tap <strong>search</strong> and type <strong><code>BotFather</code></strong>, then open the official
account — the one with the <strong>blue checkmark</strong>.</p>
<p><span class="zoomImage__wrap"><img alt="Searching for BotFather in Telegram" src="https://development-wec.wiline.com/docs/assets/images/tg-step1a-search-922bcf2cacaf250ac10c1a03546f240b.png" width="1100" height="2381" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Tap <strong>Start</strong> — BotFather replies with the commands it understands (you'll see
options like <code>/newbot</code>, <code>/mybots</code>, <code>/token</code>, and more). Send <strong><code>/newbot</code></strong>, then
follow its two prompts:</p>
<ol>
<li class=""><strong>Name</strong> — a display name, e.g. <code>My OpenClaw Agent</code></li>
<li class=""><strong>Username</strong> — must be unique and end in <code>bot</code>, e.g. <code>wiline_openclaw_bot</code></li>
</ol>
<p>BotFather confirms with <em>"Done! Congratulations on your new bot"</em> and gives you the
<strong>token</strong> — the line after <code>Use this token to access the HTTP API:</code>, like
<code>1234567890:AA...</code>.</p>
<p><span class="zoomImage__wrap"><img alt="BotFather issuing the bot token" src="https://development-wec.wiline.com/docs/assets/images/tg-step1c-token-0bec47ec554ad66500f6d053bd0a4810.png" width="756" height="1316" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>The bot token is a credential</div><div class="admonitionContent_BuS1"><p>Anyone with it controls your bot. <strong>Blur it in the screenshot above</strong>, and <code>/revoke</code>
it in BotFather if it ever leaks.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--connect-the-bot-to-openclaw">Step 2 — Connect the bot to OpenClaw<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-2--connect-the-bot-to-openclaw" class="hash-link" aria-label="Direct link to Step 2 — Connect the bot to OpenClaw" title="Direct link to Step 2 — Connect the bot to OpenClaw" translate="no">​</a></h2>
<p>On the <strong>VM</strong>, in <code>~/openclaw</code>, register the channel with your token:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels </span><span class="token function" style="color:#d73a49">add</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--channel</span><span class="token plain"> telegram </span><span class="token parameter variable" style="color:#36acaa">--token</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"&lt;your-bot-token&gt;"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Added Telegram account "default".</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="channels add succeeding" src="data:image/png;base64,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" width="641" height="242" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--activate-and-verify">Step 3 — Activate and verify<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-3--activate-and-verify" class="hash-link" aria-label="Direct link to Step 3 — Activate and verify" title="Direct link to Step 3 — Activate and verify" translate="no">​</a></h2>
<p>Restart the gateway so it picks up the channel, then check status:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart openclaw-gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels status</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># - Telegram default: enabled, configured, running, mode:polling, token:config</span><br></div></code></pre></div></div>
<p><code>mode:polling</code> means the gateway is actively polling Telegram — no inbound port or
webhook required.</p>
<p><span class="zoomImage__wrap"><img alt="channels status showing Telegram running in polling mode" src="https://development-wec.wiline.com/docs/assets/images/tg-step3-status-6393f1690ec9b270312c2f5e3a25f137.png" width="642" height="392" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--message-the-bot-pairing-gate">Step 4 — Message the bot (pairing gate)<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-4--message-the-bot-pairing-gate" class="hash-link" aria-label="Direct link to Step 4 — Message the bot (pairing gate)" title="Direct link to Step 4 — Message the bot (pairing gate)" translate="no">​</a></h2>
<p>Open your bot (<code>t.me/&lt;your-bot-username&gt;</code>), tap <strong>Start</strong>, and send a message.</p>
<p>OpenClaw treats unknown senders as untrusted — so instead of replying, the bot
returns a <strong>pairing code</strong> and your Telegram user id. This is the same security
pattern as device pairing in Part 2.</p>
<p><span class="zoomImage__wrap"><img alt="Bot replying with the pairing code" src="https://development-wec.wiline.com/docs/assets/images/tg-step4-pairing-94e777f1790fbeadab0cd42be384b80b.png" width="1100" height="2381" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--approve-the-pairing">Step 5 — Approve the pairing<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-5--approve-the-pairing" class="hash-link" aria-label="Direct link to Step 5 — Approve the pairing" title="Direct link to Step 5 — Approve the pairing" translate="no">​</a></h2>
<p>On the <strong>VM</strong>, approve the code from the bot's message:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli pairing approve telegram </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">pairing-code</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Approved telegram sender &lt;id&gt;.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># Command owner configured telegram:&lt;id&gt; (commands.ownerAllowFrom was empty).</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p>Approving the first user as <strong>command owner</strong> changes config that requires a
gateway restart — OpenClaw does this <strong>automatically</strong> (you'll see
<code>config change requires gateway restart</code> then the Telegram provider restart in the
logs). Wait a few seconds for it to come back before messaging again.</p></div></div>
<p><span class="zoomImage__wrap"><img alt="Approving the pairing on the VM — &amp;quot;Approved telegram sender …&amp;quot;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAn8AAADwCAMAAABG6fLtAAAA1VBMVEVMaXH/fFRscYI9QFceLEjR0tN8gI8RIEDd3d0WJEFHT2aU3FW9v8MwNlDZ2dpVXHCpfICHi5i3g4JIhxKhpKwA2X6WmaNiTV5gZnmGZW6tr7aNkZwsKUQAxncrRja0truP01Oacnl3sU7tclEAo2rGx8ppOUgOS03QTld8PkqSRkxVM0VALESpq7KnTU9zWGS4XE1foenmVFoeNDQuUSRRRFj4pHyFxVHSZ08FeVsBiWJAdhQiWZxml0tReUcLY1U3YxtHe7gfQG8/X0UTbM1WktZdiUk5igdqAAAAAXRSTlMAQObYZgAAAAlwSFlzAAALEwAACxMBAJqcGAAAIABJREFUeNrtnQlD4jwTgBfakga5RJTDCiKKXILH4n2s7rv7/3/SN5NMQoPFY2VdP5153zUmnaQBHidHh8k3wcLy7+QbvwUszB8L8yeaN/L5Cs3bny9sunnbfJwI+ZKqN7eLrvzcOduZK7m5uRGv69KiTvjXL+jdw/39fVJ5K+3ma9k5hbT3f8iHl06nX6qS3Vz89rnXZrk4fzcrzaSqO3EYdlZWVrabT6rENM/mk+YZ/nxWVnYW4Qd3X3l8nxe+mc914tevFzRy/evoKOFDqBSClvP2B5tzKmHm/5C/dFAIXqqyGi7+C3OvzXIv4C8Ow87K7e3v7W35PC8/V87gc75xk6YG4M/5O0vq4s4L+XuuE9dHDy9q5/4xf36QCyqZT8gfsBK8VMXPLlZxr81yir9btGjbNzcrv7dXznzK3W5Dbtv/ebtydnvbpMIVNCG/V3a2b1FTyB1QubEquvDsDEp3gBT4oLfdRN7K2Ee/jb/ebIM6NGyLfag94083JprqRtC1bbgR5dCOKUWHP6eCamx7Zwav04la4BJxdI8MHkECJGIi5C9MHo4efh39UuCpUs2fxNz10bV/dC2OsuFBLjZO+autvXBTFHMBlsrVPUyBP6/VkPlKsAf33dzI5Gb3JxVdYWMzl0sHm8VcsRVUfNNKsRIELSGoOolupZjzcfzP5mqN4MBPBkBrUtOmUDdtbqSbNq1QlwgufzW3l0vnal6uJnOe22utks3lcrM/QNOmrmeu6U7ENb9Zw7eycwMfzvbKb8rdKmyAI5QbKgQFf+UWypUmfKJnN2czFV0I+EA9GCbPYKx0EzRtZzFjBlPJbaizEh9VQW9nxp9uTG6rwp/qRreUgwTucjvHn1PBbWy+E5mw4po1KfyjXzC+XgOF9/dgDeFXSIDFX/dA2f3R/cP9teHv19H99dG9+iGPRCWMm7dK2MoEm15hL5MLa2Ij3EhvIH/ZIPBkLlg9CIuiERY2ZiBoFarQCvfCMBekw7DVCmumlb29dGZTmOokupVamBeikClCrY1wwXRNa1LTZtKgm6YbUdOmFeq1hks2wkYmzASr6bDi4d1ivSaVfOYgnPFn2tT1zDXdibimy9+2aAJRhj8YZvGzU5+f5e9sB+2f1mwamEhFFcLHfCO2wab9XrmBj9tJzEd/swMiYTwUv9HmqptS4S0qQHs6R43dmn5sb6O51jno4O1v/FXzl1TBbWyuE0J6Es3YPYy6YNUejhRaYD6AQTRzR/c+APcA/9D4QcnRfWz8fdAqv4BMsIIAcyFcNW9qNtzA8Xc1zAq/0PAJ87AR7HlwrZGthavwUdREwyBLKlShFeTDSrqQDhvSDzeoUARB2he2uqUKW7H8wQy0YK+lMyDSJFqTmrbTMNU03YiaplZMrzVcRbxlIdOqVMKgFlCbrgr+Pcf5U21SPXPNvOjMAv5+Cj/G361I4E/bOq15a0wLqahCzYhAEwkwuIn56M/A6m37OCBur8jtMzCpwJ8uvKHbkopubMf0RWUpd6O6cmb4S6rgNjbXCQMTjK33v2jxodD6pYASaNpQgD9f03gd40+Ten+Pylhd5EJretJooILNCr7Lub0sWcZQKYBhCAIwfI1WfD2oVahCa9MPi8ifB3RUqFAU98JCRprqhj/ViuUvG592VmCYg4GZEq1JTVszrZqmG1HT1IrptYYrg4WFzOZesAF2jdp0Veb5U21SPcsfveg5/n4DOTv4aTUBoh3K3eLoaPmjwu2dm9vbnSZpNle2Hf5UoSVgpenj6BdP3I8eDNkZ6u9o/uwKR7VixkxqrCl2Yvw1dQdvEtYfTgW3scROgPW7/vXrgRYf1v4pe/igFyTEn2v/cLS+VogKNSkUudlwju96OtzchEQWWn54oPnbCApZQK2myWnE5+NahSogJFnkLw9kpakQLHURBjRT3fDX0Px5cM95/ubG34bhL2v5o6bpRtQ0tWJ6reFa1XcARPMBGvd4rxfwp9qkepa/RiJ/wAF8NsDfDX5ElLP8wZz/5rdVuRW3+JkqTTAm279vfpOKLdzWi83tn2rhG0/kLQ6at/HJWnOOv58K1jn+9N3P7Pircj6shH6f3ehFuUis4DY234n4+kNquGBU9e9x/ucDWD6Q5fv314Y/MHXy4UFB6qPm9ZEem2ESmM1lgoK1f17YWC2EYEJatQq8+QfhQTFdg/mfB/M/PwiKRZg0OfyRClWw/KUzQZCnQu+g5mXCtKnuUFUMN+F+r+ePmqYbUdOmFeq1WC0U8a+mshniEJ0TldlE11FBqorZGX/UeVXPXJvxZzQVfxJXnHpA2741OcvfbyjeoUK1hQbzKa0pcEizKqZQE5C4/9dUQ+Z2bMcH+Xb4w4F7Z54/iYW4XFZLZpP7CX3avpnbAXQruI3NdSK+/jB7Kve0/gV5QBxxjWv4w9WwXv/iDiAujQE+vVr2WkEYW5ZWCgVYf+CcMNyARc1BIYRpGQxDsADxPVwB1MSBwx+p6AotmHxla7j+KLSyggrzOdA4kIKqGwJUK7IFQ3Mhk8VdtWA1mT+tSU2bQt20uZFu2rRCXYJ3KSwICfO+TK6Sh0V9bbb6cVTU1CIXW39gm1TPXDMveqZJ+39NPyGxtuFnc1Yo8fkHTPVIxf8pjcqs8KnnHy94SOE/W2hyzebTrfxsNv3b2Gai2wmZnTV6bbb+5IPEXRifBogH577qon0Q4u6CHsQHxjy9WZT6nnx8cW6DTKnI2DWYQjmtSGokqbrn/9nmnmra3miuaafXiZ2ee2HxTWnTZt7/C89/E3eqF2xf/0vB9TrsYb663vXRR3j0YPc5/g9v9NI2/4y/nzv+Cwv/rTRvYDv6D/4qHu4/wqPXVf//90YvbZP9X1gE+1+xMH8sLF+ev29vEf44mb/X+y8yf8zfUmU1+yr/ReaP+VuquI6Wq8wfy9P8GZdDdJvMZWZOlOg6SI6Exp+Q3Ca142IDn0Snc3nHJ9IrhpViMW8dJTV/lKM7UCv6Do5PJPP3BfkzLofabZKukOsgORJSQh6E5Li4CY8KZa7l+kRuwDPFQiFtC7X/IuXoDroVuoPjE8n8fUn+lMshuU3SFXIdJEdCSsiDkBwXPSiooU+Q4xOpx19bqJ2zdc7cQbdinBrjPpHM35fkT7nckNukmDmxKf7IOVEl5EFofCIPCn5rb94nUvNnCxV/lDN30K3QHRyfSObv6/JHbpOx78ip8Vc5ElJCHlzGJxLcuwC2OZ9I7QJuC8n+1WKOmdQK3cHxSWP+vi5/5DYpjHOich0kR0JKiBzjWQlO5wEsW1yfSJHLZdNFW6icEyln7mBaUXdg/r78+pdcDpXbpJnHkesgORIaH0XyICTPSjKXrk+kqBXwy2CmUDsnUo7uQG6T+g6OTyTz93X3/7x8Hr5TknZdDsmRcOajKBzPykTvRKl1H6sk3oH3/5g/MmoHlULOT3QkXI6PYuIdWJg/bY3SG5V0PtmRcDk+iol3YGH+WFiYPxbmj4WF+WP5Uvz5GyhmkYq/J0QStN/0zBflghCiiSqS32WWp/nLh3utlvlC/marlZt7oqYcqGzcj0bgm1zc1TRZJVbIwrKIP9dJ3nvEHzxAMxyBx4vNxV1NE1VihSwsz/AnK94C/mRR7m0QZgcmZ1xNn1CZFbKwPMefH2YX8AdCHG0EeZMjV9OnVGYJC8sS+DNBB/c2EmJqJ6kwfywv469RQHsWPMmfNKGjF/M3p8L8sbyMv2ytFmZqxSf5y5CFJKoKq8+qMH8sbx9/ZbEYNIpZr7AZy5Gr6VMqVpOF5Q38+SFKq7Hnx3LG1fQJFavJwvIEf5l83uyj+Pl8Nmn8VUuL+eci8lHkzazH7yrLa/lDoaIc/l7jt4bl/fwPPBQqyuPv7KPMwv4vLMwfCwvzx8L8sbC8K39+8bm9lKeOXBd/KUTqcqsvkhe/rpPvTNFf4c+vwOby6lPBTZ8+cl0s74j3ZVVffemDGMdr9mQNZCFlzTWm6K/w1wjT3uMnZ67HgZ99pyPel1N9zl1i8Z+e4zV7sibl97UThuUd+JPfv1sf6M3YGe0UItV4nDrHqpvwqYkny7/xiPc3Vqd4q/qaG5nVaOprlQOpQrnOu9ISf0KunZxcwe9XzZOrk6u1H4ISeXWFxZQT4vvV2lWTsfpT/uTVWjN+iLI9o51CpJrgps6x6nQt8WT5Nx7x/tYT4nW8VbrmRmY1mibMaw29KcS8K63m7wQwgwR+X2vCaPyjaROY/q3pIVrlvq/9OFnjCeEf83c1e/PIjYpCndqz3dUQ5h6rbsOnJpws/8Yj3t9YneKt2u7GI7OSJl3zg5zEyEh0Wv0cf2trV3LGH9i5te+U0PyPchILrpi/P+MP/njVGGLi79ZioU7t2cbqI3SPVbfhUxNOln/jEe9vrE7xVm1345FZSdNcy4S1SiDNafXz9u/HWow/iYxRYvlTuaYpZPkj/tauYktA50x4y5/yOHWPVbc8JJws/8Yj3t9YneKt2u7GI7OSprkGSw7Hmdad/0mc8a3hr8/yx+Pvn/IH40x8kbcRrnq1LIU6tfwpj1P3WHXDQ9LJ8m884v2N1Snequ1uPDIracZeSiE/520bW3+oGV+zuZbEn5SGP4njMvP3p/O/E2fTVer9Px3q1J7trjxO3WPVzbXEk+XfeMT7G6vreKu2u/HIrKRpr+XNdDLuX2v5g5mx/AH0JfG3dmX4wyHkO4+/S3v+kc0/DnWqPU6Tj1VPKn3jEe9vqm7jrZprSZFZKWO/trJYpHzBYxO5xvsvH+757xvDp/5p9ZfHW60cLCNMQ/Pq+/erK6bqw/H3xvCpf1r95fFWK5XaEuIkyeaPH9853hL7v7AwfywszB8L88fC8hr+5INcmGNh+cv8Pfx3/d/1ghwLy1/mT/73ANA9JOZYWP42f9f/4b/ruZx//R+bQZb34O8a/r++nstdX/sPbAVZ3oM//z8/xp/OXV8zfSzvw5/CbS4H4y8TyPIO/D38958Udq43ywGBHJWI5R3Wv9d6xeujwaOcvJbC52Uwy7vs/+ml7gP9xJy8/o/Xvyzv+/zDl06O3y0Wfv7LwvyxsDB/LMwfCwvzx8L8sbD8OX+fJdSpVXl1d+nLzll9nrHOwQnHqlAJ5rJS6WSp7Vo2di3pe3byRHL41Of5+zyhTq3Ka7sLIRbgG8AevA/4RugcxEbAeAlZFQ0hyEPYhBC+TJyFtKWxwqA36hppzsuPJwKnfj/h8KmGv88U6pRUXtldCAMDMXfTftrzGoU85TKFdLYRZn2wqWk4yi7t5VchmEIr52UowgcEvdHXSPMRfnJxIGkOk2D5+xShTh0V6q4R3c8iRkJopU2bNkQqNrZZyPt7KuKL3yr4lGtAPMqajouwUciaeFyNIFvBeHAQWlW/b3DNaJpO6B4RYt8hKAL8quLHmBCpJ821782m/PLhU799mlCnjgp1V9hQ1tjPGuoWMqZNgkQ3drAnKkEuAPwLEJ6NcpWCLzfChnqH0vqN2hBqOM6Y0Kp0zWiaTpi4gWYUhsAc4sfVCUaR/oExspoA2trV1cmXD5/67bOEOnVVqLsmnKnup+VPvz59zTSWK4bpSiC8zUohyFMuWwj2VBgsr6AarxUO9N8g1KHQqnSNNP1YDC0VmY34A+qaEJtNnij29NhLxvGLh0/99llCnboqhj8KZ6r7afnTr09fo8YA5QbcXRu5VZPLrm4WEa+DAK1sUeHnF3JeK0xTaFVzTWtmnSmztX8/NGjqGAcF5WP+vmr41G+fJdSpq2L4M+NvQ/On6tnXZ9fJULgK0SfB+urZ8EYsV4TXnld7AxAi2letpEGlQqFV6Rpp2k7QEuN7jD+pafyuo6jai188fOq3zxLq1FWh7rr8FSESb2GeP2rMC3PeRljLrmazB2GNcjLtZfcCn8YHWI/BWjfrhQfZDOwX6NCqdM1omk6YgMYnsANI/ImrKymbJxJNIDIGpScnXz586rdPE+rUUaHuWjZVP2UrLGQKGfv66KXr16d2/CTO7Qpm/0/isdxBlqywmveFcM/NICzA8lmFVt2ka0bTdMLZ/yP+JC45mno18kNH1W9++fCp3z5LqNN5leSnI/7iV6LfgXw268dzSU3KbEIQVaM51wnn+YeOnyqTwx1/zfCp3z5LqNPPIF8wfOq3zxLq9DPIFwyfyv4vLMwfC/PHwsL8sbD/qWD/0/fzP42tfk/Y/1Sw/+k7+5/O5Eue28X+px/G//Tr8sf+p//K/zReuPZl+WP/03/mf0qFX8/xlP1PP4L/KRV+QcdT9j/9CP6n8cKvyx/7n/4r/1Mq/IKOp+x/+iH8T23hV3M8Zf/TD+F/SoVfz/GU/U8/iP/pXCE//2X/Uxb2P2Vh/lhY3ok/j98KFuaPhfljYWH+WJg/Fhbmj4X5Y2Fh/liYPxYW5o+F+WNhYf5YmD8WFuaPhfljYWH+WJg/Fhb2f2Zh/lhYmD8W5o+FhfljYf5YWOL8yasTfi9Y/ln8oasfawwgyz/iT0LopSYDyPKP+MNwYDZaLAvLO/OH6MmrH/xusPyb+R8E6mT8WP7d+hfi//N7wfLv9v+a/Faw8P4zC/PHwsL8sTB/LCzMHwvzx8LC/LEwfywszB8L88fCwvyxMH8sLMwfC/PHwsL8sTB/LCzMHwvzx8LC/LEwfywszB8L88fCwvyxfBT+RoPhYh282D08PFys4VU9DB4TTUexnE0dHS1y6jnRZuQ0P9fko4JHElX9V7/csXqZ0dB/nLxMuqMXqY36b6r+1fg7TKVSnUUfcwcunqPGQhBOUSJRh59lm8tjUjU6bm6KuRiNk1Jp/gCIUvVZ/trl+iJyusfwQY8Hj/6QjntjIeDncXc+mZP+AlB6/Re9rf1j+ZbqX4y/fqoz6KQW2De4MBzA23a+mL+6F9VPJ95peVQ+nVBOnp5O89XIEObkxOmpV5/RKOqlx8ePPM8fEtj2F/HXe8yf7I17fTDlvZEcHI/c5BEo3TfxJ6Rg/l7MXycF738qNYD/Up1IjMDinQOU4w7kusYwav5G52AID1PjbmosU7HPqHQ6LZ1GaPsoNzmNA+TmRPnUg/9NrlrGX702fGb1ab7t1Ut1ifxF7brJee1Seyrant/24CcVAoClySL+jofI36h3PCMJ+BtDZgwmcHQ8cBNrIsc9MJGjYW8wHEZCDnq9XnfUg9IxhAjr93rHFqDBALJ9pQJleA01oVdjSHq9ZJVj5i+RP23nUijAVeq8kxoMcExOHR6mBjH+JCB5njrH8bjTTQ1jfJUBKvhf8Ye5+ikMx2VjntycGn/rM/PXVvPDskSrly+X6tXyFH6LSm2fcn6pPq2XvXZ1Wp74ZaOCd2ov4K836MnxIDruDcfHw9moqAZcgGRwPHYTa6B6o2FX9AHbXg8IPh4Me/0RjNpdqDlQOTuVPO71ITfuddF86mtDHHZ7XTkc9BapMHKP+UsZ/jpylOrAaNwfp86BPylS5+epfoy/ARhGKEx1wESOU3H8JAyq1dMJQKhzJeCtDBwa8xjPiarDnzZilr+6GnxLE8BPUK5ahqG7VK/XcaLYNioIsmVa+ijS8hcd98eD/jHU61m4xBCnemidgCg3mfE3lHb8HSGuyN8AkwiNV5w/QKobHQ9Go2NzjfjD+y9SYeSesH9dEaU62gx2BgheqjNOjWP8KRrRPKY6sQVJFYFD41cGCCmHNhBN4sw+znIwTMP4O1nAHyDTBv7KZVgBU25SUmayWi+BAawbFVUpsnNIlLblD8xcb6DM0LgXW//2wBpG/f6oN5hLzPhLA7b60VX4An+Rhmvk8KfelyEaSnttnr8EFUbuMX/nBjwYcVKHA00c8Td05n/noKJG39RwtmL2T099NcaejnwYZHXOQwNniXNzE2BvNLOG9bpGyYd/wJ+v+au2S7CI1rkqJjAGl0uyXZoYFWyptGj8FdHx8SP7B3MzjdpIL3ndxMwALSi4ih0eG/6QxuHxHH8jvXSha8NjH5I5/lwVRu4xf91UqttJjQ9Th7C6GEawEhl3Dok/sIvnXVxJnsOUEKDEkRdsYwrLZ+ZvMpl4nrZqlMPhWFm9qhppnRyslGH9awfgagmBzZen01KcP1/N/1QORtwIZnw48E4wsfy16wv5w/kWjJxR38IVjYc9nA2OpBzDrM9NSGWAkAwRHTnCUbPbs/zBONrtzfMHA3YU9Yd0DUDu9jR/UZSsgiZ2yNwl7P9JNfD2FY4wuxvg8hYYk2r/D/kD6DADsz+YJYKlHMRmc6enJb3/J02uqrcBobRsdGyudBpfjcDqQpnBcqlaivEn8qW2yQGZ5YmQ5SqYyPyMv0kpv5g/H0wdrINna9uopwdXKOuN5hOjAtmxnimqFUcPDCbxB7nj/vz4q2oAUPqaHEMtNf4eqw2gBBW8NmDu5p9/wL4YUNil2dTQ2Q+L4g9HhtET+3HT+EVfP+OQnv84J+OaMl+q4w5M9EQAfplwMV/Xi+An9+FG8tHzj0i/PDeZryBH0aMuJXcw8ukawvWMin6z+aCBxOe/h6l/NTDAft701ZWA2umHei95cfE2/rrn0T/rh/Tfo8rflT7P69j/hYX5Y2Fh/liYPxYW5o+F+WNhYf5YmD8WFuaPhfljYf74LWBh/liYPxYW5o+F+WNhYf5YmD8WFuaPhfljYWH+WJg/FpaPwN/dfmJxvlpd1tcia1nxZHg9OdTBgkZd/Eqt+33ekf4us01QU6I8EZ2P5V/xd7d++eqqW7Mqk1gYAgjVV1pS54LN5PLoUAXLHGIwBqnCFUKsBowXdygoUbEcBiZRIVw7UZTSAR6oOsvH4W93fest/LnBSqt/mb8opYO1nndGECREdjpDsHLD1OHoMNWnBILXDCFWEiXRYBSNU/0oddjtdiNTneXj8Le1tb4v9rf2d7d2TSIudy+2ti4giz93d9FKbkm5izkh4ZI1mZFXguhDvpAQA61q+aOchDhWEKtlMoG0CpGeMZAp/N6ezkKXFitB0IKBuxLsZUQ2V2sEB77wV1t74SYVis2NTC7ImMEXmNP8DQ9To74OwzpORWDmzik572AYm0NK9HUIrDSQseosH4a/u/W79Qv4ub67u75PCdrEy63L/fWti931u0soEEDlFubwGsB5ae1duYQBxCGIxgSD9mn+KDeBKBkAHsLYxmQCuXoZYg3VbejSvb10ZlPIXLB6EBaLYZjbCNOiErYywSYVikZY2IhbQwXQUA2uEDW40zn0AbXDTqdDyWEqgrH3nBKBIa4PBY6/na6tzvJh+NvdArCAv10h13cpAcbuxO4Fggc5H6zd/vrd/vru/v76xT7avvnxNypP8nn8TfFHuQhCVgkFXh6DO2MO+GuL8sSzo3QQpCGURjZsZGvhajFsSVFYzYYbOP5SIfBXE43MHH8Yp2sMhg2DBIOp66b6wJpOuipA5jklqHqO8ZsHGLiQ+ftw/G3t7ivDBxggfyrRQy5QqIZnQBSGXjSRMBxfXihrOMffFK2g5Y9yUzSIyF9dRxr3FX8QXi0/46+4FxYyMh2GQRBsFsMsgpcGo6cSVSgaLbfbCBDM4yAaeh8tGwzFEDz4XHRMIoYQahwuUKLwEzrU/5j5+2j87a+jXNytSwDMJMSfGXjB8kEZmEAsRP7iS2bFX77sxdYflJvqqKaWP4m5Of6ELLbCWhZMHMKo+ctAkg43qVA0Go/568NSdgTLDSTqEOIEp0YS538qEco8DijxOx2z4zJi/j4ef7tbysbdre/DssKnhPjDERfJE1vrWxK19vcvcRi+WI/xV6/LfF6021FU9ZA/jHenc355glFNib+8yk1c/ryDmpcJ034QFIsbaeLPCxurhXCTCl3+ZLfbOe8OR6lzWOAOwNp1+4hhBxe+lMj+CHZnIkrABPb7/eFoPITVcJeqw/ZikGEAPgJ/uNoFQwdIAWw4xmJC/AlVarcI97dgBIZ5IZjL2PjrQXhSPK+jXG4jf+VySZjcREU1Jf4wxum0XXf5y+cKYeEADOMeLD3ADEILwaqoFAqw/qBCcdBw9l9SKiTrGH/6ahoIkePUxp+kRC01hoKSrj5YAtcruBtI1UUmrDAAH+j5Bxg+OUtm4mb1GQe+qyLz/izOZzzqJ2ykSD8WXDIxPqikw+Dy8eomk18c5k/qYKxyqCKKRjqnk0g/GqHE6sdzQmZ9BuBD8SdjyXKk2p5US21+3sXyPH/7lzKWLEf8KYy2bGZY2P+FhfljYWH+WJg/FpYYf3J/X7zR8fRPZP9i9wJXPG+47Qi2ll972+W6r8pp/pX3f3UFMefdu0Qn39dItPSlpOZPwn7yyz1Qty6Xhh88TN7CrW//dbcdx07agJPbU698F5fqvirxTNhXvu529Q/eLHyMabx7k518YccfnwMsV+K+xfBMoe7/Df+X9btXGMDl8Xe5pU2J/8rbppwT1Mav42/J7qv1V9P3h/xNyu2Yd2+Sk2+7biz0EsX1LY7abf8v+B9cWHMEfgeX8Ch465ISISHdutvfugOnVOk6nlIFdRV+GhXtokruq0YutmbOrNSY3MenyfhTOV9LpbKvnjLfmepJ/q6jLpIQgSHqdAaWP8rJ8Tmm48MBJtEYnAH7h/Cz3wHHg67SWKr7apW8Lqpw+PAU7RL8JP9a42arnW7zbThlrC6VQy6eowgekfX4lxagXtvz4Yll2zOa2oOXXHdFqa0OO3b4c+pFdBooVXD6YgglTd2luU7o+1EFOOYbnDWNb/Gkrm6G05SZ3/DS+Ltb39cYKldT8DtFLytKlKspevyBNyo6xcQdT6nC1sUFPDBevyMV66KK7qvmPrvrl3eXF1SBGlPPkrf25YXy8aKmtffXhame5O+qD8juy/PUGE8u1vyZ3CGe3D7G0zrBJwYKITc4Ry9AcMqC0fVw2e6r5EY7KVe96hRO8pzW4WxZ7V9r3Gy1020efoczZI1DbrvtedXYCaBYrw3uahO/bDRaQ8VHAAAEKklEQVTJg5dcd+Hgz3J1jj+3ngRwplNJFdy+WMOrNXWX3E7Q/aiCBz8mZY98iz38K9PP8CftpfN3ofkjV1Pw9AMnwC1KlG1E/sAHYWvO8dRUAHfpLdQkFeWiSu6r5k9rfTdWgTRjLg4KZtX0jD+svsDfVY2/Q/B/6aI7leKPckMkrDNWB7WfD9Rl4A8OjD3sp2aHWy7RfZXMgU6qaKFKdfKvpYScbvHwYjFzyG2XpjJmRHW9Os4l21ZT+/Nq11383BUAcf7cetBmu92WVMHti3VUojuoLrmdoPtRBQ/Ou1WvqmQOWo5mHnVLH3/vxMzVdPdSgC+qSRSbSMA+EuA6nlIFGKAvgUJSIRdVw5axlBexCvumlTh/F6Y68berLXOiv6vir6+9Wog/yqlTi5G/c32G+1DxN/ZT3X7qr7ivavAifRKsOo4dDJ/276GEnG7zyEPbOuTm2+WZWwbVq4IFAu80q6n9ecl1qFSPJthSnD+3njmNm+yU0xcDutakLrmdoPtRBQ9cNWP8QbfJ8HnlaNn8xawT+GLN8+eLC4e/meMpVQAPLbm1bvgjmF3+3Dss5A+axrnAheVvgb+romCY6sfWH5RD4hASzd84NcIBFvkbzvO3JPdVQ8dk5t9dn+dPOd3SqdnGIVcdX+xZ+6fqAQGyXZrYQ9+92B3yZVrcuvYvXs/lz+3LjDHUpC65naD7xfjz8K+K1h+lOv0yKS1//QuTMX9/n1xNXf7I1dTCFXc8pQowqxO7FlFyUXX5wwnc/t2+rTDPHyy+7Y1gNmn5W+DvCmvWPq5cu93DPlIG3qSUGymGDH9DlTu0/CmmluW+apy9fZo7+R6czF6PYObm8kdOt0QVOeT6k0ioSVUelgGC6uHAO8FEn+9O/rwxc4aAae9enbj1XP7cvljG6A6qS7YTNDlU97P8RdM2vjbtW4zk+rTIFtTrpX7/Fz9e7Wrq8qddTa3Jch1PdQUJVcFsGRXjourwp3YYL6jCY/7w68fUtEosf8n+rl21dMCdu05f79kJk4Mp2sDyhxt4gw7wJzV/av2xLPdVu3SgtSNaJ/hUYd7k8kdOt4YqdMgFDGERrGpOcZ5F9WRZLTOMJnnwapzaEwWhJO9eSpx6Ln9uX8zHQJq6S7YTdncP7mf5K5freetbLCRNdCelvO31Up9/+I9dTeMOp8m5xArzOuYOiyvoSr5U08Q5jWR/15F+LuFMRTA3iiJ/GNsedM+6l67/6dvcV4U9ht3TLrhqdybJwTZKege1PtTXj1QSHXOjZ6f6yfWevxa5nXh8P69s3nLtW6ynfTBcT2O9/kzPf8HIwl7fW1/WAHdNOu/q7wqbaFPx6UStP2YL9Ikyf/C3Nv2s/gfybnf34s1L+6h/eDiI3rvrn9DBNu8MsHWy8T77v7AI9r9iYWH+WP7v5X8rXHZg4Fjk8wAAAABJRU5ErkJggg==" width="639" height="240" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--chat-with-your-agent">Step 6 — Chat with your agent<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#step-6--chat-with-your-agent" class="hash-link" aria-label="Direct link to Step 6 — Chat with your agent" title="Direct link to Step 6 — Chat with your agent" translate="no">​</a></h2>
<p>Back in Telegram, just talk to it — no extra setup needed; OpenClaw with your model
answers conversationally out of the box. Try something like:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">In one sentence, what's the point of putting a reverse proxy in front of my agent?</span><br></div></code></pre></div></div>
<p>It replies with a real answer — the full chain is live: your phone → Telegram →
gateway → OpenAI → reply. Your self-hosted agent is now in your pocket. (Giving it
tools and skills comes in later guides; conversational chat works immediately.)</p>
<p><span class="zoomImage__wrap"><img alt="The agent replying conversationally over Telegram" src="https://development-wec.wiline.com/docs/assets/images/tg-step6-working-7acac4effbd1c03d0657534125e3ea3b.png" width="1100" height="2381" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>Your agent is now <strong>in your pocket</strong> — a private Telegram line to your own AI, no public
webhook needed.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real-errors">Troubleshooting (real errors)<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#troubleshooting-real-errors" class="hash-link" aria-label="Direct link to Troubleshooting (real errors)" title="Direct link to Troubleshooting (real errors)" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-botfather-start-does-nothing-in-a-browser">1. BotFather "Start" does nothing in a browser<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#1-botfather-start-does-nothing-in-a-browser" class="hash-link" aria-label="Direct link to 1. BotFather &quot;Start&quot; does nothing in a browser" title="Direct link to 1. BotFather &quot;Start&quot; does nothing in a browser" translate="no">​</a></h3>
<p>Opening <code>t.me/BotFather</code> in a plain web browser shows a <strong>Start</strong> button that does
nothing — that page is just the public bot card and needs a <strong>logged-in Telegram
client</strong> to hand off to. Use the desktop/phone app, or log in at
<code>web.telegram.org</code> (link a desktop device by scanning the QR from your phone:
Settings → Devices → Link Desktop Device), then message BotFather there.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-bot-returns-a-pairing-code-instead-of-replying">2. Bot returns a pairing code instead of replying<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#2-bot-returns-a-pairing-code-instead-of-replying" class="hash-link" aria-label="Direct link to 2. Bot returns a pairing code instead of replying" title="Direct link to 2. Bot returns a pairing code instead of replying" translate="no">​</a></h3>
<p>Not an error — it's the <strong>pairing gate</strong>. Unknown senders must be approved first
(Step 5). After approval, the bot chats normally.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-proxy-headers-detected-from-untrusted-address">3. <code>Proxy headers detected from untrusted address</code><a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#3-proxy-headers-detected-from-untrusted-address" class="hash-link" aria-label="Direct link to 3-proxy-headers-detected-from-untrusted-address" title="Direct link to 3-proxy-headers-detected-from-untrusted-address" translate="no">​</a></h3>
<p>If you followed Part 2, the gateway logs this because Caddy sits in front of it.
It's a warning, not a failure. To silence it and restore correct client
detection, set <code>gateway.trustedProxies</code> to your proxy's network.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="security--sizing-notes">Security &amp; sizing notes<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#security--sizing-notes" class="hash-link" aria-label="Direct link to Security &amp; sizing notes" title="Direct link to Security &amp; sizing notes" translate="no">​</a></h2>
<ul>
<li class=""><strong>Pairing is the gate</strong> — only approved Telegram users reach the agent; the first
approved user becomes the command owner.</li>
<li class=""><strong>Polling, not webhooks</strong> — no inbound port is opened, which keeps the Part 2
hardening intact.</li>
<li class=""><strong>Sizing</strong> — on a 2-vCPU box the gateway logged <code>event loop degraded</code> under load.
For a busy bot, give it more CPU/RAM.</li>
</ul>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Chat from Telegram</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You've finished <strong>Part 3</strong> of the <strong>Self-hosting OpenClaw</strong> series. Next:</p>
<ul>
<li class=""><strong>Part 4 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">Make OpenClaw private with a NetBird mesh VPN</a></strong> <em>(next)</em> — put your agent on a private mesh and close the public ports.</li>
</ul>
<p>Then start the <strong>Self-hosting Hermes</strong> series — <strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/self-host-hermes-agent/">Self-host the Hermes Agent with persistent memory</a></strong> — for an agent whose context survives restarts.</p>
<p>Companion files for the series live in the WiLine manifests repo <em>(link coming with the repo)</em>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<p>Remove the Telegram channel (keeps the rest of OpenClaw running):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli channels remove </span><span class="token parameter variable" style="color:#36acaa">--channel</span><span class="token plain"> telegram</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart openclaw-gateway</span><br></div></code></pre></div></div>
<p>And <code>/revoke</code> the bot token in BotFather if you're done with it.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>openclaw</category>
            <category>telegram</category>
            <category>chatbot</category>
        </item>
        <item>
            <title><![CDATA[Secure OpenClaw with a Caddy reverse proxy + HTTPS]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/</guid>
            <pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Put Caddy in front of OpenClaw for real HTTPS and device-paired auth, then close the gateway's ports so the proxy is the only way in — gotchas and all.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#1F88C0" aria-label="Caddy" class="tutorialHero__docker"><path d="M11.094.47c-.842 0-1.696.092-2.552.288a11.37 11.37 0 0 0-4.87 2.423 10.632 10.632 0 0 0-2.36 2.826A10.132 10.132 0 0 0 .305 8.582c-.398 1.62-.4 3.336-.043 5.048.085.405.183.809.31 1.212a11.85 11.85 0 0 0 1.662 3.729 3.273 3.273 0 0 0-.086.427 3.323 3.323 0 0 0 2.848 3.71 3.279 3.279 0 0 0 1.947-.346c1.045.51 2.17.864 3.339 1.04a11.66 11.66 0 0 0 4.285-.155 11.566 11.566 0 0 0 4.936-2.485 10.643 10.643 0 0 0 2.352-2.894 11.164 11.164 0 0 0 1.356-4.424 11.214 11.214 0 0 0-.498-4.335c.175-.077.338-.175.486-.293a.444.444 89.992 0 0 .001 0c.402-.322.693-.794.777-1.342a2.146 2.146 0 0 0-1.79-2.434 2.115 2.115 0 0 0-1.205.171c-.038-.043-.078-.086-.113-.13a11.693 11.693 0 0 0-3.476-2.93 13.348 13.348 0 0 0-1.76-.81 13.55 13.55 0 0 0-2.06-.613A12.121 12.121 0 0 0 11.093.47Zm.714.328c.345-.004.688.01 1.028.042a9.892 9.892 0 0 1 2.743.639c.984.39 1.89.958 2.707 1.632.803.662 1.502 1.45 2.091 2.328.026.039.048.08.07.12a2.12 2.12 0 0 0-.435 2.646c-.158.114-.97.692-1.634 1.183-.414.308-.733.557-.733.557l.581.68s.296-.276.665-.638c.572-.562 1.229-1.233 1.395-1.403a2.122 2.122 0 0 0 1.907.677 11.229 11.229 0 0 1-.013 4.046 11.41 11.41 0 0 1-1.475 3.897 12.343 12.343 0 0 1-2.079 2.587c-1.19 1.125-2.633 2.022-4.306 2.531a10.826 10.826 0 0 1-3.973.484 11.04 11.04 0 0 1-3.057-.652 3.304 3.304 0 0 0 1.417-2.294 3.275 3.275 0 0 0-.294-1.842c.18-.162.403-.363.656-.6 1.015-.955 2.353-2.303 2.353-2.303l-.47-.599s-1.63.972-2.801 1.728c-.307.198-.573.378-.777.517a3.273 3.273 0 0 0-1.516-.611c-1.507-.198-2.927.672-3.487 2.017a10.323 10.323 0 0 1-.695-1.078A10.92 10.92 0 0 1 .728 14.8a10.35 10.35 0 0 1-.2-1.212c-.164-1.653.103-3.258.629-4.754a12.95 12.95 0 0 1 1.087-2.288c.57-.968 1.248-1.872 2.069-2.656A11.013 11.013 0 0 1 11.808.797Zm-.147 3.257a3.838 3.838 0 0 0-3.82 3.82v2.36h-.94c-.751 0-1.377.625-1.377 1.377v3.8h1.46v-3.718h9.354v6.264H10.02v1.46h6.4c.751 0 1.377-.625 1.377-1.377v-6.43c0-.751-.626-1.377-1.377-1.377h-.94v-2.36a3.838 3.838 0 0 0-3.82-3.819zm0 1.46a2.371 2.371 0 0 1 2.36 2.36v2.36H9.3v-2.36a2.372 2.372 0 0 1 2.36-2.36zm10.141.392a1.253 1.253 0 0 1 1.296 1.434c-.049.319-.217.59-.453.78-.266.213-.61.318-.968.264a1.253 1.253 0 0 1-1.045-1.42 1.255 1.255 0 0 1 1.17-1.058zM5.384 17.425a2.02 2.02 0 0 1 1.917 1.298c.116.3.159.628.114.967a2.015 2.015 0 0 1-2.249 1.728 2.016 2.016 0 0 1-1.727-2.25 2.017 2.017 0 0 1 1.945-1.743z"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/"><span class="skillTracker__dot" data-state="locked">1</span><span class="skillTracker__skill" data-state="locked">Deploy your own AI assistant</span></a></li><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">2</span><span class="skillTracker__skill" data-state="current">Real HTTPS + auth</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Chat from Telegram</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Private mesh access</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">Run it on WEC models</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Chat from WhatsApp</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>In <a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Article 1</a> we got OpenClaw running —
but only over plain HTTP, with an <code>allowInsecureAuth</code> workaround. Here we put
<a href="https://caddyserver.com/" target="_blank" rel="noopener noreferrer" class="">Caddy</a> in front of it as a reverse proxy: real HTTPS,
device-paired auth, and the gateway's raw ports closed so the proxy is the only
way in. Every command and error below is from the actual deploy.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>Continues from Article 1 on the same WEC Instance — <strong>Ubuntu 22.04.5 LTS</strong>, OpenClaw
<strong>2026.6.8</strong>. We use <strong>Caddy 2</strong> (<code>caddy:2</code>). Because this box has no public domain,
we use Caddy's <strong>internal CA</strong> (<code>tls internal</code>) for self-signed HTTPS; the
<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#going-to-production" class="">Going to production</a> section shows the one-line swap to real
Let's Encrypt certificates once you have a domain.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<p>Caddy terminates HTTPS and reverse-proxies to the OpenClaw gateway over Docker's
internal network. The gateway stops publishing its own ports — the only entry
point becomes Caddy on <code>:443</code>.</p>
<!-- -->
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class=""><strong><a class="" href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/">Article 1</a> completed</strong> — OpenClaw running in <code>~/openclaw</code></li>
<li class="">Shell access to the VM</li>
<li class="">A hostname for the gateway. With a public domain, use it (and get real
Let's Encrypt certs). On a private box, we use <code>openclaw.local</code> + a hosts entry.</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--confirm-the-starting-point">Step 1 — Confirm the starting point<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-1--confirm-the-starting-point" class="hash-link" aria-label="Direct link to Step 1 — Confirm the starting point" title="Direct link to Step 1 — Confirm the starting point" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> ~/openclaw</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p>You should see <code>openclaw-gateway</code> <strong>Up (healthy)</strong>, publishing <code>18789/18790/3978</code>.
That's our starting state: working, but HTTP-only.</p>
<p><span class="zoomImage__wrap"><img alt="docker compose ps showing the gateway running" src="data:image/png;base64,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" width="567" height="163" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--write-the-caddyfile">Step 2 — Write the Caddyfile<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-2--write-the-caddyfile" class="hash-link" aria-label="Direct link to Step 2 — Write the Caddyfile" title="Direct link to Step 2 — Write the Caddyfile" translate="no">​</a></h2>
<p>Caddy's config: serve HTTPS and proxy everything to the gateway. <code>tls internal</code>
uses Caddy's own local CA — no public domain needed.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> Caddyfile </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"># Caddy selects its certificate by SNI (the hostname in the TLS handshake).</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"># A bare IP sends no SNI, so we use a hostname. With a public domain, put your</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"># domain here and drop `tls internal` for automatic Let's Encrypt.</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">openclaw.local {</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    tls internal</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    reverse_proxy openclaw-gateway:18789</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">}</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p>The proxy target is <code>openclaw-gateway:18789</code> — Caddy reaches the gateway by its
<strong>Compose service name</strong> over the shared Docker network.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--add-caddy-to-compose">Step 3 — Add Caddy to Compose<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-3--add-caddy-to-compose" class="hash-link" aria-label="Direct link to Step 3 — Add Caddy to Compose" title="Direct link to Step 3 — Add Caddy to Compose" translate="no">​</a></h2>
<p><code>docker compose</code> auto-merges a <code>docker-compose.override.yml</code>, so we add a <code>caddy</code>
service without touching OpenClaw's official compose file:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> docker-compose.override.yml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">services:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    image: caddy:2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    restart: unless-stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    ports:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - "80:80"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - "443:443"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - ./Caddyfile:/etc/caddy/Caddyfile:ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - caddy_data:/data</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - caddy_config:/config</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy_data:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy_config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><br></div></code></pre></div></div>
<p><code>caddy_data</code> persists the local CA + certs across restarts.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--start-caddy">Step 4 — Start Caddy<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-4--start-caddy" class="hash-link" aria-label="Direct link to Step 4 — Start Caddy" title="Direct link to Step 4 — Start Caddy" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p>Caddy comes up alongside the gateway with <code>443</code> published, and generates its
internal CA + a certificate on first run.</p>
<p><span class="zoomImage__wrap"><img alt="Caddy container up with 443 published" src="https://development-wec.wiline.com/docs/assets/images/caddy-step4-up-d53a3b63c4e346428a8e733249b2049a.png" width="654" height="359" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p><code>docker compose up -d</code> also starts the one-shot <code>openclaw-cli</code> helper; it idling
here is harmless — we invoke it with <code>docker compose run --rm</code> when needed.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--verify-https">Step 5 — Verify HTTPS<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-5--verify-https" class="hash-link" aria-label="Direct link to Step 5 — Verify HTTPS" title="Direct link to Step 5 — Verify HTTPS" translate="no">​</a></h2>
<p>Hit the gateway's health endpoint <strong>through Caddy</strong>. <code>--resolve</code> makes curl send
the hostname (SNI) while pointing at the box:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-k</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--resolve</span><span class="token plain"> openclaw.local:443:127.0.0.1 https://openclaw.local/healthz</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># {"ok":true,"status":"live"}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="healthz returning over HTTPS via Caddy" src="data:image/png;base64,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" width="882" height="41" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Hit a <code>tlsv1 alert internal error</code>?</div><div class="admonitionContent_BuS1"><p>That happens if you used a <strong>bare IP</strong> as the site address — see
<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#1-tlsv1-alert-internal-error" class="">Troubleshooting #1</a>. Use a hostname.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--re-secure-the-gateway">Step 6 — Re-secure the gateway<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-6--re-secure-the-gateway" class="hash-link" aria-label="Direct link to Step 6 — Re-secure the gateway" title="Direct link to Step 6 — Re-secure the gateway" translate="no">​</a></h2>
<p>Now traffic arrives over <code>https://openclaw.local</code>. Two config changes:</p>
<ol>
<li class="">Add the HTTPS <strong>origin</strong> to <code>allowedOrigins</code> (the Control UI rejects unknown origins).</li>
<li class="">Turn <strong><code>allowInsecureAuth</code> back off</strong> — HTTPS is a secure context, so the
Article 1 workaround is no longer needed.</li>
</ol>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> --no-deps </span><span class="token parameter variable" style="color:#36acaa">--entrypoint</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> openclaw-gateway </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  dist/index.js config </span><span class="token builtin class-name">set</span><span class="token plain"> --batch-json </span><span class="token string" style="color:#e3116c">'[{"path":"gateway.controlUi.allowedOrigins","value":["https://openclaw.local"]},{"path":"gateway.controlUi.allowInsecureAuth","value":false}]'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose restart openclaw-gateway</span><br></div></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-7--pair-your-browser">Step 7 — Pair your browser<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-7--pair-your-browser" class="hash-link" aria-label="Direct link to Step 7 — Pair your browser" title="Direct link to Step 7 — Pair your browser" translate="no">​</a></h2>
<p>Open <strong><code>https://openclaw.local/</code></strong> in your browser (on a private box, first map
<code>10.80.4.212 openclaw.local</code> in your machine's hosts file — <code>/etc/hosts</code> on
macOS/Linux, or <code>C:\Windows\System32\drivers\etc\hosts</code> edited as Administrator
on Windows).</p>
<p><strong>You'll hit a certificate warning first</strong> — <em>"Your connection is not private"</em>
(<code>NET::ERR_CERT_AUTHORITY_INVALID</code>). This is expected with <code>tls internal</code>: the
certificate is signed by Caddy's <strong>local CA</strong>, which your operating system doesn't
trust. Click <strong>Advanced → Proceed to openclaw.local (unsafe)</strong> to continue. (With a
real domain + Let's Encrypt, the certificate is publicly trusted and this warning
never appears — see <a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#going-to-production" class="">Going to production</a>.)</p>
<p><span class="zoomImage__wrap"><img alt="Browser cert warning — click Advanced, then Proceed" src="https://development-wec.wiline.com/docs/assets/images/caddy-step7-cert-warning-f6015d4f4498bdd7f495c01e08e91cf5.png" width="920" height="744" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Now the page loads — but with <code>allowInsecureAuth</code> off, the gateway requires
<strong>device pairing</strong> before this browser can use the Control UI. That's the secure
flow doing its job. The screen shows a request id; approve it on the host:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli devices approve </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">request-id-from-the-screen</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="Device pairing required screen" src="https://development-wec.wiline.com/docs/assets/images/caddy-step7-pairing-95202988ec2160fdef5dcd2e33fce67f.png" width="1100" height="508" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Back in the browser, click <strong>Connect</strong> again — it pairs and loads the dashboard.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-8--lock-down-direct-access-the-real-hardening">Step 8 — Lock down direct access (the real hardening)<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-8--lock-down-direct-access-the-real-hardening" class="hash-link" aria-label="Direct link to Step 8 — Lock down direct access (the real hardening)" title="Direct link to Step 8 — Lock down direct access (the real hardening)" translate="no">​</a></h2>
<p>The gateway still publishes <code>18789</code> to the host, so <code>http://&lt;vm-ip&gt;:18789</code> is
<em>still reachable in plain HTTP</em>, bypassing Caddy. Close it: stop publishing the
gateway's ports so Caddy on <code>:443</code> is the only door. Caddy still reaches it over
the internal network.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">cat</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain"> docker-compose.override.yml </span><span class="token operator" style="color:#393A34">&lt;&lt;</span><span class="token string" style="color:#e3116c">'EOF'</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">services:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  openclaw-gateway:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    # Unpublish the gateway's host ports — Caddy reaches it internally, so the</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    # only entry point is HTTPS on :443.</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    ports: !reset []</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    image: caddy:2</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    restart: unless-stopped</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    ports:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - "80:80"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - "443:443"</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">    volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - ./Caddyfile:/etc/caddy/Caddyfile:ro</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - caddy_data:/data</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">      - caddy_config:/config</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c"></span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">volumes:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy_data:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">  caddy_config:</span><br></div><div class="token-line" style="color:#393A34"><span class="token string" style="color:#e3116c">EOF</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><br></div></code></pre></div></div>
<p><code>ports: !reset []</code> clears the ports the base compose published.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-9--prove-its-locked">Step 9 — Prove it's locked<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#step-9--prove-its-locked" class="hash-link" aria-label="Direct link to Step 9 ��— Prove it's locked" title="Direct link to Step 9 — Prove it's locked" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-s</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-m</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">5</span><span class="token plain"> http://</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">vm-ip</span><span class="token operator" style="color:#393A34">&gt;</span><span class="token plain">:18789/healthz </span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">" &lt;- direct HTTP (should FAIL)"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-sk</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--resolve</span><span class="token plain"> openclaw.local:443:127.0.0.1 https://openclaw.local/healthz </span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token builtin class-name">echo</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">" &lt;- via Caddy (should work)"</span><br></div></code></pre></div></div>
<p>The first returns nothing (connection refused) — the insecure door is gone. The
second still returns <code>{"ok":true,"status":"live"}</code>.</p>
<p><span class="zoomImage__wrap"><img alt="Before/after: direct port refused, HTTPS works" src="data:image/png;base64,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" width="1089" height="107" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Your OpenClaw is now reachable <strong>only</strong> over HTTPS, device-paired.</p>
<p><span class="zoomImage__wrap"><img alt="OpenClaw Control UI over HTTPS" src="https://development-wec.wiline.com/docs/assets/images/caddy-control-ui-https-814c25fc8dd38568d2226856190421ba.png" width="1100" height="663" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>put real HTTPS and auth in front of any self-hosted service</strong> — and prove the
back door is closed.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real-errors">Troubleshooting (real errors)<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#troubleshooting-real-errors" class="hash-link" aria-label="Direct link to Troubleshooting (real errors)" title="Direct link to Troubleshooting (real errors)" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-tlsv1-alert-internal-error">1. <code>tlsv1 alert internal error</code><a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#1-tlsv1-alert-internal-error" class="hash-link" aria-label="Direct link to 1-tlsv1-alert-internal-error" title="Direct link to 1-tlsv1-alert-internal-error" translate="no">​</a></h3>
<p>Hit when the Caddyfile site address was a bare IP (<code>https://10.80.4.212</code>):</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">curl: (35) error:0A000438:SSL routines::tlsv1 alert internal error</span><br></div></code></pre></div></div>
<p>Caddy's logs showed the cert was obtained fine. <strong>Cause:</strong> TLS picks a certificate
by <strong>SNI</strong> (the hostname in the handshake), but connections to a bare IP send no
SNI — so Caddy can't match a cert and aborts. <strong>Fix:</strong> use a hostname site address
(Step 2). A reader with a real domain never hits this — the domain <em>is</em> the SNI.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-device-pairing-required">2. Device pairing required<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#2-device-pairing-required" class="hash-link" aria-label="Direct link to 2. Device pairing required" title="Direct link to 2. Device pairing required" translate="no">​</a></h3>
<p>On the first HTTPS connect after turning off <code>allowInsecureAuth</code>:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Device pairing required — This browser needs one-time approval from the Gateway</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">host before it can use the Control UI.</span><br></div></code></pre></div></div>
<p><strong>Cause:</strong> this is the <em>correct</em> secure flow — with insecure auth off, each new
browser must be approved. <strong>Fix:</strong> <code>docker compose run --rm openclaw-cli devices approve &lt;request-id&gt;</code>. (The CLI may log <code>scope upgrade pending approval … using local fallback</code> but still completes the approval.)</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-neterr_cert_authority_invalid">3. <code>NET::ERR_CERT_AUTHORITY_INVALID</code><a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#3-neterr_cert_authority_invalid" class="hash-link" aria-label="Direct link to 3-neterr_cert_authority_invalid" title="Direct link to 3-neterr_cert_authority_invalid" translate="no">​</a></h3>
<p>Expected with <code>tls internal</code> — the cert is signed by Caddy's local CA, which your
OS doesn't trust. Click through on a private box. With a public domain + Let's
Encrypt (below), the cert is trusted and there's no warning.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="going-to-production">Going to production<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#going-to-production" class="hash-link" aria-label="Direct link to Going to production" title="Direct link to Going to production" translate="no">​</a></h2>
<p>With a real domain, swap two lines in the Caddyfile — point it at your domain and
drop <code>tls internal</code>:</p>
<div class="language-diff codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-diff codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">- openclaw.local {</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">-     tls internal</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">+ openclaw.example.com {</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      reverse_proxy openclaw-gateway:18789</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  }</span><br></div></code></pre></div></div>
<p>Then point the domain's DNS <strong>A record</strong> at the VM's public IP and open <strong>80 + 443</strong>
to the internet. Caddy automatically provisions and renews a <strong>Let's Encrypt</strong>
certificate — trusted, no warning, no <code>--resolve</code> or <code>/etc/hosts</code> needed. Update
<code>allowedOrigins</code> to <code>https://openclaw.example.com</code>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="security-notes">Security notes<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#security-notes" class="hash-link" aria-label="Direct link to Security notes" title="Direct link to Security notes" translate="no">​</a></h2>
<ul>
<li class=""><strong>Single entry point</strong> — only Caddy's <code>:443</code> is exposed; the gateway has no host
ports (Step 8).</li>
<li class=""><strong>Device pairing</strong> — every browser needs one-time approval (<code>openclaw devices</code>).</li>
<li class=""><strong>Origin allowlist</strong> — the Control UI only accepts <code>https://openclaw.local</code>.</li>
<li class=""><strong>Rotate secrets</strong> — regenerate <code>OPENCLAW_GATEWAY_TOKEN</code> if it has ever been
shown (screenshots, screen-shares).</li>
</ul>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Real HTTPS + auth</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>You've finished <strong>Part 2</strong> of the <strong>Self-hosting OpenClaw</strong> series. Next:</p>
<ul>
<li class=""><strong>Part 3 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Add a Telegram channel</a></strong> <em>(next)</em> — talk to your now-secured agent from your phone.</li>
<li class=""><strong>Part 4 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">Make OpenClaw private with a NetBird mesh VPN</a></strong> — put it on a private mesh and close the public ports.</li>
</ul>
<p>Companion files (<code>Caddyfile</code>, <code>docker-compose.override.yml</code>) live in the WiLine
manifests repo <em>(link coming with the repo)</em>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<p>To remove just Caddy and reopen the direct ports, delete <code>docker-compose.override.yml</code>
and <code>Caddyfile</code>, then <code>docker compose up -d</code>. To stop everything:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose down</span><br></div></code></pre></div></div>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>docker</category>
            <category>openclaw</category>
            <category>caddy</category>
            <category>https</category>
            <category>security</category>
        </item>
        <item>
            <title><![CDATA[Deploy OpenClaw on a WEC Instance via Docker Compose]]></title>
            <link>https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/</link>
            <guid>https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/</guid>
            <pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[From a fresh WEC Instance to a self-hosted OpenClaw agent that actually answers — Docker Compose, your own model key, and every real error and fix from a live deploy.]]></description>
            <content:encoded><![CDATA[<div class="tutorialHero"><img class="tutorialHero__brand" src="https://development-wec.wiline.com/docs/assets/images/openclaw-wordmark-003352a1a7f02afc3bd877ae6f2dc175.png" alt="OpenClaw"><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#2496ED" aria-label="Docker" class="tutorialHero__docker"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"></path></svg><span class="tutorialHero__plus">+</span><svg viewBox="0 0 24 24" fill="#0a0a0a" aria-label="OpenAI" class="tutorialHero__openai"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997z"></path></svg></div>
<div class="skillTracker" tabindex="0" aria-label="Skill path: 0 of 6 skills earned"><div class="skillTracker__ring" style="--skillTracker-pct:0%"><span class="skillTracker__ringLabel">0<!-- -->/<!-- -->6</span></div><div class="skillTracker__panel"><div class="skillTracker__header"><span class="skillTracker__title">🎯 Skill path</span><span class="skillTracker__count">0<!-- -->/<!-- -->6<!-- --> earned</span></div><div class="skillTracker__series">Self-hosting OpenClaw</div><ul class="skillTracker__steps"><li><span class="skillTracker__step"><span class="skillTracker__dot" data-state="current">1</span><span class="skillTracker__skill" data-state="current">Deploy your own AI assistant</span></span></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/"><span class="skillTracker__dot" data-state="locked">2</span><span class="skillTracker__skill" data-state="locked">Real HTTPS + auth</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">3</span><span class="skillTracker__skill" data-state="locked">Chat from Telegram</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/"><span class="skillTracker__dot" data-state="locked">4</span><span class="skillTracker__skill" data-state="locked">Private mesh access</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/run-openclaw-on-wec-models/"><span class="skillTracker__dot" data-state="locked">5</span><span class="skillTracker__skill" data-state="locked">Run it on WEC models</span></a></li><li><a class="skillTracker__step" href="https://development-wec.wiline.com/docs/tutorials/add-whatsapp-channel-openclaw/"><span class="skillTracker__dot" data-state="locked">🏆</span><span class="skillTracker__skill" data-state="locked">Chat from WhatsApp</span></a></li></ul><div class="skillTracker__footer">Finish this tutorial, then mark it complete below to earn the skill.</div></div></div>
<p>Your AI assistant doesn't have to live in someone else's cloud.</p>
<p>Deploy a self-hosted <a href="https://openclaw.ai/" target="_blank" rel="noopener noreferrer" class="">OpenClaw</a> AI agent on a WEC Instance with
Docker Compose — from spinning up the VM to an agent that actually answers, using
your own model API key. Every command, version, and error below was captured
from a real deployment on a WEC Instance.</p>
<!-- -->
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Reproducibility</div><div class="admonitionContent_BuS1"><p>You bring your own model API key (OpenAI in this guide); WiLine handles the
hosting. This was reproduced on a WEC Instance — <strong>Ubuntu 22.04.5 LTS, 8 vCPU,
15 GiB RAM, 25 GB free disk</strong>. OpenClaw version <strong>2026.6.8</strong>, image
<code>ghcr.io/openclaw/openclaw:latest</code>.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youll-build">What you'll build<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#what-youll-build" class="hash-link" aria-label="Direct link to What you'll build" title="Direct link to What you'll build" translate="no">​</a></h2>
<p>A single OpenClaw <strong>gateway</strong> container, configured with your model provider,
reachable through its web Control UI and CLI.</p>
<!-- -->
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="prerequisites">Prerequisites<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#prerequisites" class="hash-link" aria-label="Direct link to Prerequisites" title="Direct link to Prerequisites" translate="no">​</a></h2>
<ul>
<li class="">A WiLine Edge Cloud account</li>
<li class="">A model API key — this guide uses <strong>OpenAI</strong>; Anthropic and local models work too</li>
<li class="">An SSH key pair on your machine (<code>~/.ssh/id_ed25519</code> or similar)</li>
<li class="">About 20 minutes</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-1--provision-the-vm-on-wiline">Step 1 — Provision the VM on WiLine<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-1--provision-the-vm-on-wiline" class="hash-link" aria-label="Direct link to Step 1 — Provision the VM on WiLine" title="Direct link to Step 1 — Provision the VM on WiLine" translate="no">​</a></h2>
<p>Spin up a WiLine VM that meets these requirements:</p>
<table><thead><tr><th>Requirement</th><th>Value</th></tr></thead><tbody><tr><td>OS</td><td>Ubuntu 22.04 LTS (or 24.04 LTS)</td></tr><tr><td>Compute</td><td>≥ 2 vCPU / 4 GB RAM — OpenClaw needs ≥ 2 GB; the first run can be OOM-killed (exit 137) on 1 GB hosts</td></tr><tr><td>Disk</td><td>30 GB NVMe — 10 GB fills up fast once Docker images and logs land</td></tr><tr><td>Access</td><td>Your <strong>SSH public key</strong> added at deploy time, so you can log in without a password</td></tr></tbody></table>
<p>The portal walkthrough is already documented — follow these and come back:</p>
<ul>
<li class=""><strong><a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/">Deploy a Virtual Machine →</a></strong></li>
<li class=""><strong><a class="" href="https://development-wec.wiline.com/docs/cloud_portal/platform/compute/ssh_keys/">Configure an SSH Key →</a></strong></li>
</ul>
<p>Once it's running, SSH in:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">ssh</span><span class="token plain"> ubuntu@</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">your-vm-ip</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-2--verify-the-box">Step 2 — Verify the box<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-2--verify-the-box" class="hash-link" aria-label="Direct link to Step 2 — Verify the box" title="Direct link to Step 2 — Verify the box" translate="no">​</a></h2>
<p>Before installing anything, confirm what you're working with — the OS version and
the resources available. Note these numbers; they're useful when you later compare
performance or open a support ticket.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">lsb_release </span><span class="token parameter variable" style="color:#36acaa">-a</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">nproc</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">free</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-h</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">df</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-h</span><span class="token plain"> /</span><br></div></code></pre></div></div>
<p>On our WEC Instance: <code>Ubuntu 22.04.5 LTS</code>, <code>8</code> vCPU, ~<code>15 GiB</code> RAM, <code>25 GB</code> free disk.</p>
<p><span class="zoomImage__wrap"><img alt="Terminal showing OS version, CPU, RAM and disk" src="https://development-wec.wiline.com/docs/assets/images/step2-box-specs-c821280774bf4f5ec9205092f6646004.png" width="584" height="316" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-3--install-docker--compose">Step 3 — Install Docker + Compose<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-3--install-docker--compose" class="hash-link" aria-label="Direct link to Step 3 — Install Docker + Compose" title="Direct link to Step 3 — Install Docker + Compose" translate="no">​</a></h2>
<p>Check whether Docker is already present:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">--version</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose version</span><br></div></code></pre></div></div>
<p>If it's not installed, follow Docker's official guide for your distro —
<strong><a href="https://docs.docker.com/engine/install/ubuntu/" target="_blank" rel="noopener noreferrer" class="">Install Docker Engine on Ubuntu</a></strong>.
The quickest path is Docker's
<a href="https://docs.docker.com/engine/install/#install-using-the-convenience-script" target="_blank" rel="noopener noreferrer" class="">convenience script</a>,
followed by adding your user to the <code>docker</code> group so you can run it without
<code>sudo</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://get.docker.com </span><span class="token operator" style="color:#393A34">|</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">sh</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">usermod</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-aG</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> </span><span class="token environment constant" style="color:#36acaa">$USER</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">newgrp </span><span class="token function" style="color:#d73a49">docker</span><br></div></code></pre></div></div>
<p>You need <strong>Docker Compose v2</strong> (the <code>docker compose</code> subcommand, not the legacy
<code>docker-compose</code> binary).</p>
<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_BuS1"><p>If Docker is already present, the <code>get.docker.com</code> script detects it and warns
rather than reinstalling — don't force it on a box with running containers. On a
clean VM it installs from scratch. This deploy ran on a host where Docker
<strong>29.1.3 / Compose v5.0.2</strong> was already installed.</p></div></div>
<p><span class="zoomImage__wrap"><img alt="docker --version and docker compose version output" src="data:image/png;base64,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" width="582" height="234" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-4--get-the-compose-file">Step 4 — Get the Compose file<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-4--get-the-compose-file" class="hash-link" aria-label="Direct link to Step 4 — Get the Compose file" title="Direct link to Step 4 — Get the Compose file" translate="no">​</a></h2>
<p>Create a project directory and pull OpenClaw's official <code>docker-compose.yml</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> openclaw </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name">cd</span><span class="token plain"> openclaw</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-fsSL</span><span class="token plain"> https://raw.githubusercontent.com/openclaw/openclaw/main/docker-compose.yml </span><span class="token parameter variable" style="color:#36acaa">-o</span><span class="token plain"> docker-compose.yml</span><br></div></code></pre></div></div>
<p>The file defines two services — <code>openclaw-gateway</code> (the long-running agent
gateway) and <code>openclaw-cli</code> (a one-shot CLI that shares the gateway's network).
By default it <strong>builds from source</strong>; we'll point it at the prebuilt image
instead.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-5--configure-env">Step 5 — Configure <code>.env</code><a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-5--configure-env" class="hash-link" aria-label="Direct link to step-5--configure-env" title="Direct link to step-5--configure-env" translate="no">​</a></h2>
<p>Create a <code>.env</code> next to the compose file. Pin the prebuilt image and set a
dashboard token:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token assign-left variable" style="color:#36acaa">OPENCLAW_IMAGE</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">ghcr.io/openclaw/openclaw:latest</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">OPENCLAW_GATEWAY_TOKEN</span><span class="token operator" style="color:#393A34">=</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">paste output of: openssl rand </span><span class="token parameter variable" style="color:#36acaa">-hex</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">3</span><span class="token operator file-descriptor important" style="color:#393A34">2</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Keep <code>.env</code> out of git</div><div class="admonitionContent_BuS1"><p>It holds your gateway token (and any keys). Add it to <code>.gitignore</code>. Commit a
<code>.env.example</code> with placeholder values instead.</p></div></div>
<p>Pull the image:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose pull</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose pull completing with the Pulled line" src="data:image/png;base64,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" width="583" height="176" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-6--pre-create-the-config-directory">Step 6 — Pre-create the config directory<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-6--pre-create-the-config-directory" class="hash-link" aria-label="Direct link to Step 6 — Pre-create the config directory" title="Direct link to Step 6 — Pre-create the config directory" translate="no">​</a></h2>
<p>OpenClaw bind-mounts <code>~/.openclaw</code> into the container. Create it <strong>as your own
user first</strong> — otherwise Docker creates it as <code>root</code> and the container (which
runs as user <code>node</code>, uid 1000) can't write to it:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-p</span><span class="token plain"> ~/.openclaw ~/.openclaw-auth-profile-secrets</span><br></div></code></pre></div></div>
<p>If you skip this, you'll hit a permission error on first run — see
<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#troubleshooting-real-errors" class="">Troubleshooting</a>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-7--onboard">Step 7 — Onboard<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-7--onboard" class="hash-link" aria-label="Direct link to Step 7 — Onboard" title="Direct link to Step 7 — Onboard" translate="no">​</a></h2>
<p>This is where you bring your own model — onboarding prompts for your provider and
API key. This guide uses <strong>OpenAI</strong>, but Anthropic and local models work the same way.</p>
<p>Run OpenClaw's onboarding through the gateway container. It's interactive — it
asks for your provider and API key and writes the config:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> --no-deps </span><span class="token parameter variable" style="color:#36acaa">--entrypoint</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> openclaw-gateway </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  dist/index.js onboard </span><span class="token parameter variable" style="color:#36acaa">--mode</span><span class="token plain"> </span><span class="token builtin class-name">local</span><span class="token plain"> --no-install-daemon</span><br></div></code></pre></div></div>
<p>Walk through the prompts:</p>
<ul>
<li class=""><strong>Continue</strong> past the personal-use security notice → <strong>Yes</strong></li>
<li class=""><strong>Setup mode</strong> → <strong>QuickStart</strong></li>
<li class=""><strong>Provider</strong> → <strong>OpenAI</strong> (or your provider), then paste your <strong>API key</strong></li>
<li class=""><strong>Default model</strong> → keep the suggested one (<code>openai/gpt-5.5</code>)</li>
<li class=""><strong>Channel</strong> → search <code>skip</code> → <strong>Skip for now</strong> (messaging channels are a later
guide)</li>
<li class=""><strong>Skills / Hooks</strong> → <strong>No / Skip for now</strong> (configure later with
<code>openclaw configure</code>)</li>
<li class=""><strong>Hatch your agent</strong> → <strong>Hatch later</strong> (we start the gateway as a service next)</li>
</ul>
<p>It ends with <code>Onboarding complete.</code></p>
<p><span class="zoomImage__wrap"><img alt="Onboarding — selecting the OpenAI provider" src="data:image/png;base64,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" width="626" height="391" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p><span class="zoomImage__wrap"><img alt="Onboarding — &amp;quot;Model configured: openai/gpt-5.5&amp;quot;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnMAAAGICAMAAAAd05Z7AAAAWlBMVEURIEAtN1JcYXCanKUAAABFQURMU2o7OjoiLkt3eYjb29xkaXv/elN5eHitra8BDSQbJkLCw8jCYE2Dh5VyQEc+Rl5XVVWYUUrocFGNjIz5uC4qxD8yXyRDfBN3msLgAAAACXBIWXMAAAsTAAALEwEAmpwYAAAc90lEQVR42u2dCYOquBKFIbkgi9IBRHRm3v//m6+qUkGCaHt7sRfPmbmNZKmE8FlJ0O5K2rbtk5c/L6beQdAnyhw2xFlCSofk5eXlcDhkGwj6RGXZwRhijal7edkcDhgS6AE6mI1AR8jBx0EPcnY0wbKbA3LQA6Ej5v5gYoUeOb0e2M1hHKBHQpe8FGAO+oxJ1O9V15j7M0seu+22G+MS4z+kRdp//5L++9we73af9C47L2YXL+7Vbre+/j3sdoeLJHNnr24uqc3u8NXDds818DM4+bmTE8Mdl57HI5Mlf6ZRyYg4Vje7/uwf1XxM/lV9KnNNc+3KTDzItrEns87GSlrTNJyc2aaxh/kL4zPukG0OV3p8kbG7z6ahTpwy370126fm8N5he4RjM4wY/TRC24HemjtKEvKiN82MOUbuSP9tu3P2P5MukftU6K4OnmnsnEBrdwezfmubJrtEzp6axjA39MJm5xen5r3MHaKO/Q1zByLONifhf9W2bTY/gDnxAGZyzYaBM/zG5xfrfm4U5ERjNLF6jdHE6vUJ0+vBnqiT9kCDtyOeaLwJCEMvTpbPMyN+aWeZGyu5B0P/072lQifLLutAB/IaXHDHTIoZrn7aMG8MrfBxokrTi+Z0mvjY+XYPbJEaO3lHqFlkZ+LCG2VPu9v4jmUED5emo+X3+WTTcL0sZLOZ02SVS9H18lUIc8as0MytGN8kXx23F66LW9J8Yc53iMdoJx174HQ7wSVOjyfbjFd25oqf6wS52NH988+lo/v33890dAqB4ZnKsuvhO2zICVg5Nwd2TJneJco48GzZEGY8L1kqkZG3oKNlz3Uyh7MZug9yR/jHjgyK1enFYc6HUrmTut5ayGnmvkiMGu87jXRM3GbDHosYtjOb0ruQbXxXz2bE8xpaWXPiYemg+R0VPLQfBu4fvSf1ujj9JPkHPyi+QzJo3AfzFY6OLvmQhZXvNebEyem/b8EcY2BnzBkhRObWwNzOsqc7sYuQwlzKFzhlMrcezmZ2YfprGNSdR2t6ofPgTpYeVHMn99k3xy35pbF3jWxSyonR7JAdOF2s0MfWB/FYzeGgVnxN6V3IPqljm5o7eGe78S91IcoOzWTmINT46zhJk2yZih+MXJcfFs43Jx620KGMlgz0/iDOH/nIf6KL/RxPr9lN5ibivglzstCZMeePS+bEWdFkq8w0u8m3KHPepkyIN/zcZLORm2hs4z2f9Me7JumMeKSDlhOjB/Z8gbmdnHjnSyV2kt5Ii7vNlH1hxqiz28zXc9bakJ/F12Eb3+h5eA66jpO3h3aILNJkbVaWmQ9wdNN6ztxirvNTK//70rm18fOMvHFnb+QL5g5CAr3hef1td8HP7XyB3eTnssld+lvK+SYs46YX6ucO8gUburnnVjxzGedsJgflyykAu8nPUZ7xKzNL36Lw1PqaYj1kn3gyP5sJ67nAXDY91ZEnDpnvQqZvPSmb8duSWwjDIhe7k7k1dIguu9k0ttk9kjmGTfathnqj+9brzOke4vi1ewgaNFmN6HGnC5Uzc7yQyYzs9IgEek1rZVn063qOh13WUryY4QnOm/HM0W3Y2Wnf2mSzF9EeU5aAEXPTeu4ULcSEudOuOTO3k2WVZX/r59bZDjZkH7yTOiz2rcpctvBM4obpina6nuN9CLfGG5xpWE5yZcqc7xCb3fht+kMd3eXzuavMybMSQe7uZyWfsVDw6DBzOx4zmSlOM+b8M6yTZPPJ6bz7O/mt3UEfuRl+wyux4QHH9edzEXM7wfCSOc442Zg52dVMc6v0/uD7YDYL5s7Zu5OZPXKZns+tMuernhrZp1q+br/1lTrTsPiHPTq3Soc2jTi75ks/h1jL+YbPhLNXzjfzD1XkEYWdnjgsq1w+NDssDKx+PPOODmc3u+1Td/xQMYLh1ucQ4Ylwtng4eH2cvvn3hGbM3f3ZV/aAz77+5muA5nD1+evhsSvoux9CNvc/gt5EcNrm5388HjG3+dlfQV27G+Z73iKjDxHuKnuIrjIDcxAE5iAwB0FgDgJzEJiDIDAH/SrmNn8g6JFK/uQJBH2u0mIuMAd9HXMZhiYWBgTMgTkwB+agz2Mu7937my2LDygylGUN5n4xc3lVtQ3f4r4arprr8+tnc5mFjZWS5kYzqrKtKjD3i5kzVV83lUmS4gYLVX/97BZQKyXvYI75B3O/mjmTuNYO9Geu+dTanv/edWr5Z+LopC0HU1lj0jDz6ZnhrMmML2mqummbIin4rI7rrRbJWznTQ2hWmcsb7RSY+33MJW2blk3F67mmqmxrk7bqmypPbGVrW9qKFZZYejZUbd9OiVrS0ERtq5KsNH3Vx/VWi+hZSNRmlTlisrRg7pcy15A7KZW5Omn6vGpymnSLqrk6t/bExpQfXpmqdZSdc4nwL7lVRM+mRGn2zNzr+w0w93P93MScTGaleCg7BGZWmLNcfJr6tKRhJ9X2sh1ZMrdaRM/0oM0G5gztJT5gMw3mviNzA99nZa7xm9l65pquMDckrmqXTmzwQKWEzTU/Ny+iZ3rIw0Qc9hBmNnuDuV/EnLUt3/KyMnlgzrUt/Q2imhddphY/mPNBJWd51da8BFP5khM7jRV/FdVbKxIzF5ol5qgvQ2OGnpkz1zfKYO7HPp8zfj5tA3PJQNNaWycFbSn4htf+oPJnPBHaaebzJXPPDu8zStqJxPXWiuSBQJ+ozRJz9IQupZOqcdwzC+ae4bOv1C/f3eD8Yb6a92cujVb4WlJru3nJm0VWm43LDw7M4fPWBJ+3QmAOzIE5MAfmMMRgDsyBOTAH5iAwB+bAHJiDwByYA3NgDsyBOTAH5sAcmANzEJgDc2AOzEFgDsyBOTAHgTkwB+bAHJiDMCBgDsyBOTAHgTkwB+bAHATmwByYA3NgDkO8kNtgDMDcY7UpMAafzhwUC6jAz0FgDoLAHATmIAjMQWAOAnMQBOYgMAdBYA4CcxAE5iAwB4E5MAeBOQjMQRCYg8AcBIE5CMxBYA7MQWAOAnMQBOYgMAdB72AOgsAcBOYg6AuY24dVX7bf70NisXdrZa8kryrr3LIF6BmZOx7pR7edk7PtAhrb7TYkdsclXONeityPzziVnVqAnpK57T4ptttijTnGZMJvv6y5Hf+SOZclYO7pmHP/c5fMdezPiv1xeySs3NhxStEdj+OMuYIpKcbj8bjfH4nPbp/tt91+XxCL3fY8Zx79WdeNR0LS2+w6n3MUl7poQUtCv5U597//LqA7Em/0L9seR3Z5hMxI1BAHHXu2wFxHjtBJ3rhnn8jZrD3heuwmaOis68TKlgqrzY5d4bErRj+Dxy34krhhv5U5Qi7ZLKEjYvx/+8QxJh3PfHQgPzZOzMlr+TExN82t5NZmzHXiEoWmcTzb3HuAqe1FC74kbthvZY6QS5Klp2NXtCVWmAeaOZkNmjTFiXWBOSezomwBLpnLok1HxpT56TRRm+wkO2/A6cLw3EIHH/ermRPcBLyIuZEJ6DwtzBUv1MKOwTPnN5wjwSZza8aHK8wVAlVgzhPI1sfA3KIFMPfL13ME3RI5Ys5lvBMgDIiMjLGT1dZ+38l0SE/oMs/UntdqwicVGdmD8fO7BXP7kffAXaiwF9rIkzphjp7LLVoAc7993/rfBXLJ0XsrN/JU53hNPx67JCNMjsIcPaELj+Z8nuODX99tvdfzNsIDvS6bvJfalGWezz0mixbA3K9/PncjBIcrhKxCl3tFcfm5hOaFIuQgFzaIQLdiM9ZKCxA+b32r9ltwBOYey1w2Oow9mMP3SiB8TxgCcxAE5qDfzpxLsfCHHsrcUJbl1S1GWg4EZclKMarQxzBXEFXpVaDyshZXCOKgj2NuYKiMSevC1Fwqr+shKeisNHRWm9KBOehjmctr+ZGWdZ6XRWLqdCiLgs4G4qwo2Q2COehDmTNGmaMide7KgYAbCjkbGEjJB3PQZ/g5nklzcnckYs4JcwThwC/BHPSR67kAG28nUppM/cZCmCtkwzqAOehDmXM0jdK+NS1dWte8aXBuSJU52V/Uhr6SVOIZHvRxz+doOhXsytKQjyvqsqwDc7KRpckVz+egT/gcIi2DI3PwaNBDPm+VPQQEPZC5At+wg/C9EgjMQRCYg8AcBObAHATmIDAHQWAOAnMQBOYgMAdBYA4CcxCYgyAwB4E5CAJzEJiDIPxtVwjMQWAOgr4Lc8X+RlAH53/l1b3yW693hEhfKYIQ6U/K3N5HMLyW2XH4SwnVeqXIHSHSrxVB4NbnZC6TQHDXgOo4QpfjSKvZNWd4R4j0a0XA3HPGRucI1BQ4OEQ19yHLMz1z5N8yAXMG5esh0ruOI8GG+OeLInEQdugJY6NLNN/uqFHNNWS5j3i+59iY4qPmzN0RIp28I51v1VhcJA7Cjjv1jLHRPXNbjWquIctDxPNum0iI6jlzd4RIJ9/JcV6nCOvzImMUhB136hljo3db9XMSU1pDlmskYCdrvSxm7o4Q6R3V3LOP1Ajr8yJxEHbcqWeMjT4G2CSquYYsVz48NOOSuVdDpE/M7Zd7CGZuHoQdd+oZY6PzBCgrOIlqriHLzz7JuePR7XkXUJwfn7wWIl2ZmyKsz4vEQdhxp54xNro+nwtRzX3Ico14fux0rRc9n3s9RHo3ui2t5KYI61GROAg79JSx0Qt+9HaOav56yPJXQ6TPihaXRRZB2KFn/bwVUc2hRzOHqOYQvlcC4XvCEATmIDAHgTnERod+S2x0SUwoiWIzxXkcos7V2LCAuY+Oje4TKUmCvs7zmLnaYPjB3EfHRveJnMQBhRfMmdoFKzlbSGvcDTD33tjomijZw5I5IwFgvRVxnwYzLZhL3hsbXRPLPK+XeRRkuJaw1mJFQr8iBieYe39sdE0sTZ4mF8ylPCWrFToWOaZWMPfu2OghMSQt1nO8qVUrvPKrB9wMMJe8MzZ6SFTUojyGLacMb0Wew+BegLl3x0afEnVmXT6fow2E81b4gQp2EGAueVBsdG8lLfGtPDD3yNjowwA3B+YeGxs9z/GcBMzheyUQmIPAHASBOQjMQRCYg8AcBObAHATmIDAHQWAOAnMQBOYgMAeBOTAHgTkIzEEQmIPwt10hCMxBYA6C3sDcq4HPk7dHUXez35iN8xAX/YmZk8BKWRzHPPmgKOpTIsVqWuRNgTehJ2Suy7LMJa+z8IYo6pLI9Sg88CIPzD1vbHSNRqjR0DWOuUY1pwCFFFN6fHMUdZ8oScfjIo/MZl0In95xHyTaOvQEsdE5vOWeo6BLNHSNY65RzTn4YHeOkPm3UdQ1kZMyP8lGzNFkm01WFEvoGWKj03rueOzOwc2VHB/VXLgb3xpFXRMzZpR9WMwcpWWhIUdz8H67x317jtjoOrdOQX+3o+4WeL/Qde+Joq6JGXnCsVjy6PcToaGRXSzCVDxJbPQrzO014HTyjijqmjglLfzcUcKj76eVHzYVzxIb3a/niok5iWMeoppHzP11FHVNVNTivPN6zodPZ5O4bc8SG93vGqbg5hLHPEQ1j5j76yjqmqjMXT6foxQXwqcXW8RJf6LY6AtpHPO1EOl/HUX9Hnkr4xafS+Dz1uSBUdS7Dm4OzCUPjaLedXtsWsEcBOF7whCYgyAwB4E5CMyBOQjMQWAOgh7AXG4Mh53GAYe3H/6SuZqDYeKAw3sOYA4HMIcDmANzOHwpc0VKwgGH9xzwrATC8zkIzEEQmIPwPWEIAnMQmIMgMAeBOQjMQRCYg34/c/hyIQ5v/ELmm5nDFyBweOOXRsAcDmAOBzAH5nD4Jszhy4U4vPELmXhWAuH5HASBOQjMQWAOgsAcBOYg6OHM1TcrDWVZv7eXsxby/pU/1q/tva3Z+aUMpe3Xx6x0V6uvNLscnis9W7uwoSxqc9eQXy35U5gzVVW1/d02WrtM6WdDUrZV9d5ezlroq+Eyf6W9tzU7a2igMWjXyarSq9VXml0Oz5WerV0YpVXNXUN+teTPYa6sm8q8nbmqj8fjA5krVpBbb69/H3O2vUbWLeZWml15S671bO3C6qq4rL1q82rJn8NcnuRVnaS2JXdn6WdJF2RaOiYNX1ndpj4vcX3bVtPF5g2VSQZTWWPSpKC8tg5DrBX0hqpNXyRv66ZtilCBbVJLay0MrfieUOFWe/GdtbZnazOb1IK/opAXNeQoz5hiUSTcX0q1Lm8pttS586F6dLWRTdNKPS2i1bUviwsL49kmtlx2V236FkqyMFCqlvzJzBkagyJpq76p8rZqeZoZaLptq9ryFEDj4/MSS6ln5uiWl5bSWHXSVE3PHsjfBa2gBb1NLUJzeWurMlRQm2stpGVTOZn8pcKt9mLmmqqy7cymrW2pVxTyooYGsWkWRQJz1PXKiLs7dz5Uj642sqn1tIhW1yKLC4sVdzcanqKlG9QWP38Pwes5QiuvmtxU9K5K6ZqrnoawqJqBLtdUtebx2czR0/uwmOa6nH9ODGiFqaDY1CKmah0d9ExtrreQlP7WSIVb7V0wR+D0arPwSx+9Is1bNtQ2F0UmdhpKtBNz0pep+vxqY5taL2ZuKhJdWDzrRt2Nh0feHHnyG5gr+f1Yynud3t8Fv6ktj4m4poJ+al7Jb9vzjTLkvXj7JaMmi+KJAa0wrUfEphbhuTxpez1Tm+sthFsjFW61t2SuTc6dGPxdna5I88wlc3GRiZ0hYk76MlWfX21sU+vFzE1FoguL96RRd+Ph4eQ2+RXM5eTIHS/plI+cmRsSR9dHbzBy/ppX0hjV1Wzxatj7BwbSpJ/5uTpaA4tNLWJkvu71TG1eaUFvzaC35mp7S+bEVahNdRzhinzesiFlbl5ktoeoaZtFeeXU+an6/Gpjm1pvYk6qT0UWF3bp57Qv8fBQS1XV/w7m+O45XkbbWvnIq7aWpQb5Flox+7yBl1DnRVBjhp7Hom3z2hCctvJ3weShQsycFtGR1jO1udqCuKM83Jpb7U2HOXOhE7TyM1zBX5HPWzYkzMVFJnZqXnDklaUKgbmp+vxqY5taT4to9anI/MIWNynqbjw8KS3m2tnkan4AgFeZc22VDrzQr9veMR8yX/Cuy79xfR6v3/tpjFJKqxopUsnquCr9bMMPpLSC3lC16YvkOtJagW3ySmWlBZm12NdKhZvthcOcuWCzaKSCXpHmLRoK3m9eZNoLVK2MEPdz1nmpHl1tZFPraRGtHq42urDlY5Sou2rTt8DOj7hz5+GxP/1ziHS+I3LRmebFaW5weuTk1F03FtLcxdmQpkUue7e1FmLdbO/KBc8qxle02pBbSy3cWueL5Pbw0Gx662pvKepusLna38Hh89a/V89zZPsLw1GvPkv+tVf7o5hLa2vL9BeO9bD2UfGvvVp8rwTC33aFwByYg8AcBOYgCMxBYA6CwBwE5iAIzEFgDgJzEATmIDAHQWAOAnMQBOYgMAeBOQgCcxCYg6BHMRf95lK31xf7ruteb2vfHanU/njPL9ZNpqEnZm67pR/H7fz3eLfHQMhWcl9Bbiuluu09OE2moadmbkyK7TpzTNKrTR23shku7vJgYO7pmHP/c5fMHdmfFSN5K5oiC/ZtxyQ7ylnMnOukjC+53/JZwfXGsWBnx6alejcyhlTmeOy4qBpT09BTMef+998FdAwO/SNohA/GY3t0cjYumDtuj7R005JM3nHbycy6HbPOM+erd8qcFJmM+Tzcm6dijpBLNkvoPBf0356n2D1DsT0SSfuRfdOcucID0/mSI0PGRXTJ1zFzWv3MHP3r1JjmqbHsG6nI8Kj8s5gj5JJk6elkiiT/w8jwi9FDs/Xz4Jy5vcy2iZYcec8gGM2Ym6oH5sJOZMuJ4zddz22STQFkPoc5wU3Ai5iTyZD9nEyCe2LkOG67yz1E4RHSkleYk+r+EJhTY5r3LYfLbYDMJ63nCLolcsRcsafVPU2VnV+ekVs68tw5HmVuHcf5NDx2496XvGRu3CdafdweZdvgs9SY5n3P8cLk+mn71v8ukEt0e+o3lS4s9GVr0C2e0BU+0Zf0zM3Xc/xi2oJsz34uGPvOewgw93nP527MIS6TRU1WhEX+Spli784lV7SnBbks+/ZxCW8s+76rJjD3Yz9v5ckzfr78QwTmfixzWUePgX/i/QNz+F7Jg1XgWQm+Jww/B+bAHATmwByYA3NgDsyBuS9hzjkwB+YeyVxaciwWMAfmHsZcakUpmANzj2Ku9MyVYA7MPYg5Z1UOzIE5MAfmfi9zDZgDc/BzYA7MQRgXMAfmfjZzDZgDc/BzYA7MQRgXMAfmsJ4Dc9Dn+rmhtP0ypazvabTO/7LCe5T34e+upMaBuR/FXF5VbTMjZKDzNi5StlUlL/rbvz7W2mWFVcVW+jf+SlpfDfqqaYsbLRQVq149A3Nfw5yp+rqpzHRu23TtBsuh6u9jbqqwqtjKKzavqgjI1ZcIzW2mla3rOl09A3Nfs54zxJsjWlLbtn3iTNsaUyRF37ZtnbMHaWtFaDCVNSbcL0vlS6pHFdqS/p4EVaiClQVzedOy6/R5sZVwplZCn+TMtHS0Tuvlbd20TejZ0AZnXLSNb70ttULcQlqV83GJzsDcV/k58nF0A9uqb6p8kLnHJE3V9OQAq9Q7DUHIRvNSW7U8Cw9V27eUaOlIzHkrC+aIkdImmhdb0bNgJUzvclazfeqLr2fozBIxvmdp2VROLaSJtq4V4hZSet2aZPUMzH0hc02bVw3dVsKL3QYt8nqGLWYunrPaluepqifCiqoZKobKTlYWzJUF29S8lblVrUwTs5zVlFBUwaapWkeF82Ch9Mzl7Li0da0QWqhLkqMDQTgt8OIzMPeVfq4U52CVOVmgv8KcLSpTV5ZvPVdnK3ayEjFnyCP2bspbYU6tTM5PzmrqBCGk9QyT0vbas8Cck0raulYILdCU3LbOL/nsfHsdnYG5r1nPDexNdDJS5tJE5taB7ud15nJmbkhcRczlfC8nK4s9BFFXT3mrzImViTk5Y+LrqtR6hmFj5qRngTm/e9XWtcLlviSNKEvB3Jf7OdoNVIPjvYOtp7m1oSmIELJ95ZkzuUySeW1i5nJig5ZZA6+yKjtZ0Qp+edaYoa/qcwszK3qmVqbHN3JGG1LaGqRaT5nTnolvy/2kynOrtK4V4haGPs95VZjIvD6dgbmvfj5HN2HgLQER0TS6tqdNqeNJ0TMnD9xokT65kLZ3zFzCUx9tLqlCz7tStaIVfF8orWrclDe3Es7USni+J2ec0+ahZ7lnTnsmZdrp0ZyVfoYKUQs5z8ylGLWzMzD3LT6HSOePVlM3PwS54fKPZjlfL9ROV/6slhvcPC+24s9cVE/OaGZ313vmgfIP6YY0LXJ2jKHCvAU35D5VMqczMIfPWy8lO5jkvs8kaMJt3f0VwBy+V7Kqob/3c9S05j9Y8BcVwByYS/C9EjAH5iB8fw7Mwc+BOQjMgTkwB+awnsPYgjn4OTAH5sAcBObAHNZzYA6CnwNzYA7MgTkwB+awngNz8HMQxgXMgTkwB+YgrOfAHPwcmIPAHJgDc2AO6zkIzMHPgTkw90RyG4wBmHusNgXG4PPWc9CqQAz8HATmIAjMQXg+B4G5oii9mysLMAc9iLnUM5eCOehRzBVpyX9+sgBz0MOYozWdkwOYgx7GnArMQWAOAnMQBOYgMAdBYA4CcxCYA3PQlzLnwBz0WOYGMAc9krmUmCvAHPRQ5l6KFMxBD2RuSF5edHIFc9AjmEtp0/pnSMEc9DDmhjxx5OgKMAc9iLk0f0kcregEOjAHfT5z6VAQc+ToiiF1YA76dObSIS9emDmC7mUY0sJhbKDP/H0IIi4n1hwzR9D9IV9HKRD0GaqNMXlO0+ofQs4zR2s6wg6CPlcvfl5NXoi3RP67qoQc43pOceNsLvfyidpca3Sz+QDrxQv0QUr0v/8Dc+NGlyP5FVUAAAAASUVORK5CYII=" width="627" height="392" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-8--apply-gateway-config-and-start">Step 8 — Apply gateway config and start<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-8--apply-gateway-config-and-start" class="hash-link" aria-label="Direct link to Step 8 — Apply gateway config and start" title="Direct link to Step 8 — Apply gateway config and start" translate="no">​</a></h2>
<p>Onboarding wrote your provider and model. Now set the gateway's runtime options —
run mode, network bind, and the Control UI origins it will accept — then start it
as a background service.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> --no-deps </span><span class="token parameter variable" style="color:#36acaa">--entrypoint</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">node</span><span class="token plain"> openclaw-gateway </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  dist/index.js config </span><span class="token builtin class-name">set</span><span class="token plain"> --batch-json </span><span class="token string" style="color:#e3116c">'[{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"lan"},{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789"]}]'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose up </span><span class="token parameter variable" style="color:#36acaa">-d</span><span class="token plain"> openclaw-gateway</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose up -d creating the gateway container" src="data:image/png;base64,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" width="629" height="169" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="step-9--sanity-check">Step 9 — Sanity check<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#step-9--sanity-check" class="hash-link" aria-label="Direct link to Step 9 — Sanity check" title="Direct link to Step 9 — Sanity check" translate="no">​</a></h2>
<p>Container health:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose </span><span class="token function" style="color:#d73a49">ps</span><br></div></code></pre></div></div>
<p>You want <code>Up ... (healthy)</code>. Then hit the health endpoint:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">curl</span><span class="token plain"> http://127.0.0.1:18789/healthz</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># {"ok":true,"status":"live"}</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="docker compose ps healthy and the healthz JSON response" src="https://development-wec.wiline.com/docs/assets/images/step9-health-2a25a28bc6b53dd20c385259373d72a5.png" width="626" height="217" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>Confirm your provider key is usable:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli models status</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># openai ... api_key=1 ... status=usable</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Redact before screenshotting</div><div class="admonitionContent_BuS1"><p><code>models status</code> prints a masked key prefix (<code>sk-proj-…</code>). Crop or blur it before
publishing the screenshot.</p></div></div>
<p>Now the real end-to-end test. The <code>agent</code> command needs a target, so list agents
first, then message the default one (<code>main</code>):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli agents list</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># - main (default)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli agent </span><span class="token parameter variable" style="color:#36acaa">--agent</span><span class="token plain"> main </span><span class="token punctuation" style="color:#393A34">\</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token parameter variable" style="color:#36acaa">--message</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Reply with exactly one word: working"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># working</span><br></div></code></pre></div></div>
<p>If it replies <code>working</code>, the full chain — gateway → OpenAI → response — is live.</p>
<p><span class="zoomImage__wrap"><img alt="The agent replying &amp;quot;working&amp;quot; — the proof shot" src="https://development-wec.wiline.com/docs/assets/images/step9-agent-working-91514c6b42235714c1348d5ce9f6e9b9.png" width="628" height="391" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<p>The Control UI is at <code>http://&lt;your-instance-ip&gt;:18789/</code>; paste your
<code>OPENCLAW_GATEWAY_TOKEN</code> into Settings to log in.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Opening it from another machine</div><div class="admonitionContent_BuS1"><p>Browsers block the Control UI over plain HTTP at a remote IP ("Secure browser
context required"). Reach it over an SSH tunnel so it loads as <code>127.0.0.1</code> — see
<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#3-control-ui-secure-browser-context-required" class="">Troubleshooting #3</a>.</p></div></div>
<p><span class="zoomImage__wrap"><img alt="OpenClaw Control UI in the browser, logged in" src="https://development-wec.wiline.com/docs/assets/images/step9-control-ui-fe1df8b0971ad1a8192ff40d2ea6856e.png" width="1100" height="567" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="troubleshooting-real-errors">Troubleshooting (real errors)<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#troubleshooting-real-errors" class="hash-link" aria-label="Direct link to Troubleshooting (real errors)" title="Direct link to Troubleshooting (real errors)" translate="no">​</a></h2>
<p>These are errors we actually hit during this deploy — not hypotheticals.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-eacces-permission-denied-mkdir-homenodeopenclawstate">1. <code>EACCES: permission denied, mkdir '/home/node/.openclaw/state'</code><a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#1-eacces-permission-denied-mkdir-homenodeopenclawstate" class="hash-link" aria-label="Direct link to 1-eacces-permission-denied-mkdir-homenodeopenclawstate" title="Direct link to 1-eacces-permission-denied-mkdir-homenodeopenclawstate" translate="no">​</a></h3>
<p>Hit on the first onboarding run when <code>~/.openclaw</code> didn't pre-exist:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">[openclaw] The CLI command failed.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">[openclaw] Reason: Failed to open the plugin state database.</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">| EACCES: permission denied, mkdir '/home/node/.openclaw/state' | EACCES</span><br></div></code></pre></div></div>
<p><strong>Cause:</strong> Docker auto-created the bind-mount source <code>~/.openclaw</code> owned by
<code>root</code> (<code>0:0</code>). The container runs as user <code>node</code> (uid 1000), so it can't write
inside that root-owned directory.</p>
<p><strong>Fix:</strong> give the directory to uid 1000 (which is also your <code>ubuntu</code> user):</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">sudo</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">chown</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-R</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">1000</span><span class="token plain">:1000 ~/.openclaw</span><br></div></code></pre></div></div>
<p>Pre-creating the directory yourself (Step 6) avoids this entirely.</p>
<p><span class="zoomImage__wrap"><img alt="EACCES permission error in the terminal" src="https://development-wec.wiline.com/docs/assets/images/error1-eacces-fbbf355224203ecfccd68552fcf0bcb3.png" width="968" height="246" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-no-target-session-selected">2. <code>No target session selected</code><a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#2-no-target-session-selected" class="hash-link" aria-label="Direct link to 2-no-target-session-selected" title="Direct link to 2-no-target-session-selected" translate="no">​</a></h3>
<p>The first time we ran <code>agent</code> without specifying who to talk to:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Error: No target session selected. Use --agent &lt;id&gt;, --session-key &lt;key&gt;,</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">--session-id &lt;id&gt;, or --to &lt;E.164&gt;. Run openclaw agents list to see agents.</span><br></div></code></pre></div></div>
<p><strong>Cause:</strong> <code>openclaw agent</code> runs one turn against a specific agent/session; with
no channel configured there's no implicit target.</p>
<p><strong>Fix:</strong> list agents and pass the id explicitly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli agents list          </span><span class="token comment" style="color:#999988;font-style:italic"># shows: main (default)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose run </span><span class="token parameter variable" style="color:#36acaa">--rm</span><span class="token plain"> openclaw-cli agent </span><span class="token parameter variable" style="color:#36acaa">--agent</span><span class="token plain"> main </span><span class="token parameter variable" style="color:#36acaa">--message</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"..."</span><br></div></code></pre></div></div>
<p><span class="zoomImage__wrap"><img alt="The &amp;quot;No target session selected&amp;quot; error" src="https://development-wec.wiline.com/docs/assets/images/error2-no-session-c405ca89ca87bd411080165c514046e5.png" width="839" height="241" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-control-ui-secure-browser-context-required">3. Control UI: "Secure browser context required"<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#3-control-ui-secure-browser-context-required" class="hash-link" aria-label="Direct link to 3. Control UI: &quot;Secure browser context required&quot;" title="Direct link to 3. Control UI: &quot;Secure browser context required&quot;" translate="no">​</a></h3>
<p>Opening the Control UI from another machine (e.g. macOS) at the VM's IP over
plain HTTP, the page refuses to connect:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">Secure browser context required</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">This page is running over plain HTTP, so the browser cannot create the</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">device identity the Gateway expects.</span><br></div></code></pre></div></div>
<p><strong>Cause:</strong> browsers only expose the crypto APIs OpenClaw needs in a <em>secure
context</em> — HTTPS, or <code>localhost</code>/<code>127.0.0.1</code>. A remote <code>http://&lt;ip&gt;:18789</code> is
neither, so it's blocked in the <strong>browser</strong>, regardless of the server-side
<code>gateway.controlUi.allowInsecureAuth</code> flag.</p>
<p><strong>Fix:</strong> tunnel the port over SSH so the browser talks to <code>127.0.0.1</code> (a secure
context). On your local machine:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">ssh</span><span class="token plain"> </span><span class="token parameter variable" style="color:#36acaa">-L</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">18789</span><span class="token plain">:127.0.0.1:18789 ubuntu@</span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">your-vm-ip</span><span class="token operator" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<p>Then open <code>http://127.0.0.1:18789/#token=&lt;your-token&gt;</code> locally. The proper
long-term fix is HTTPS via a reverse proxy — the next guide.</p>
<p><span class="zoomImage__wrap"><img alt="The red &amp;quot;Secure browser context required&amp;quot; message in the Control UI" src="https://development-wec.wiline.com/docs/assets/images/error3-secure-context-3e5792a56fae6a2ea2a8f9e0f0aa4c3f.png" width="658" height="895" class="zoomImage " loading="lazy"><span class="zoomImage__badge" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path><path d="M11 8v6M8 11h6"></path></svg></span></span></p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="security-notes">Security notes<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#security-notes" class="hash-link" aria-label="Direct link to Security notes" title="Direct link to Security notes" translate="no">​</a></h2>
<p>On startup the gateway logged warnings worth acting on before any public
exposure:</p>
<ul>
<li class=""><strong>Binding to a non-loopback address</strong> — the gateway listens on the LAN. Don't
expose port 18789 to the public internet without auth in front of it.</li>
<li class=""><strong><code>gateway.controlUi.allowInsecureAuth=true</code></strong> — flagged dangerous. Run
<code>docker compose run --rm openclaw-cli security audit</code>.</li>
<li class=""><strong><code>plugins.allow</code> is empty</strong> — non-bundled plugins (e.g. <code>codex</code>) may
auto-load. Set an explicit allowlist of trusted plugin ids.</li>
</ul>
<p>Hardening this behind a reverse proxy with HTTPS is the subject of the next
guide.</p>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Skill unlocked 🏅</div><div class="admonitionContent_BuS1"><p>You can now <strong>deploy and operate your own AI agent</strong> on a box you control.</p></div></div>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-youve-accomplished">What you've accomplished<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#what-youve-accomplished" class="hash-link" aria-label="Direct link to What you've accomplished" title="Direct link to What you've accomplished" translate="no">​</a></h2>
<p>You went from a bare WEC Instance to a working, self-hosted AI agent:</p>
<ul>
<li class="">Provisioned and verified an Ubuntu VM on WEC</li>
<li class="">Stood up the OpenClaw gateway with Docker Compose and the official prebuilt image</li>
<li class="">Wired in your own model provider (OpenAI) and confirmed it end-to-end — the agent
replied over the CLI and the Control UI</li>
<li class="">Hit (and fixed) the real permission, session, and secure-context issues along the
way</li>
</ul>
<p>The agent is yours: your key, your box, your data. From here it's about making it
secure, reachable, and useful.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-can-you-do-with-it">What can you do with it?<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#what-can-you-do-with-it" class="hash-link" aria-label="Direct link to What can you do with it?" title="Direct link to What can you do with it?" translate="no">​</a></h2>
<p>OpenClaw isn't a chatbot — it's an <strong>autonomous agent that can act on your box</strong>. Now
that it's running, here's what it unlocks (some features need extra configuration, and
we cover the big ones later in this series):</p>
<ul>
<li class=""><strong>Run real work on the machine.</strong> It can execute shell commands and read/write files
— <em>"check what's using the disk,"</em> <em>"tail the logs and summarize the errors"</em> — with
configurable sandboxing for how much you let it touch.</li>
<li class=""><strong>Talk to it from your phone.</strong> OpenClaw connects to <strong>WhatsApp, Telegram, Discord,
Slack, Signal, and iMessage</strong>, so you delegate tasks conversationally without SSH.
<em>(We wire up Telegram in the next guide.)</em></li>
<li class=""><strong>Browse and act on the web.</strong> Built-in browser automation lets it navigate sites,
fill forms, and extract data autonomously.</li>
<li class=""><strong>Write and run code.</strong> It integrates with <strong>Claude Code</strong> for autonomous coding —
writing and modifying code, running tests, and even opening PRs.</li>
<li class=""><strong>Work in the background, proactively.</strong> It runs 24/7, can execute background tasks,
and does "heartbeat" check-ins instead of only reacting when you message it.</li>
<li class=""><strong>Grow with you.</strong> A community skill library (<strong>ClawHub</strong>), self-written tools, and
multi-agent orchestration mean it gets more capable over time — backed by persistent
memory that carries context across conversations.</li>
</ul>
<p>Typical real uses people run: inbox and calendar management, document processing,
infrastructure tasks on the box, content pipelines, and autonomous code testing.</p>
<hr>
<div class="skillComplete"><span class="skillComplete__medal" aria-hidden="true">🎯</span><div class="skillComplete__text"><div class="skillComplete__title">Finished this tutorial?</div><div class="skillComplete__sub">Mark it complete to earn <strong>Deploy your own AI assistant</strong> on your skill path.</div></div><button type="button" class="skillComplete__btn">Mark as complete ✓</button></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>This is <strong>Part 1</strong> of the <strong>Self-hosting OpenClaw</strong> series on running production-grade
AI infrastructure on a WEC Instance. Coming up:</p>
<ul>
<li class=""><strong>Part 2 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/secure-openclaw-caddy-https/">Secure OpenClaw with a Caddy reverse proxy + HTTPS</a></strong> <em>(next)</em> — put HTTPS in front of the gateway so you can drop the <code>allowInsecureAuth</code> workaround and reach the Control UI from anywhere, safely.</li>
<li class=""><strong>Part 3 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/add-telegram-channel-openclaw/">Add a Telegram channel</a></strong> — talk to your agent from your phone.</li>
<li class=""><strong>Part 4 — <a class="" href="https://development-wec.wiline.com/docs/tutorials/netbird-mesh-vpn-openclaw/">Make OpenClaw private with a NetBird mesh VPN</a></strong> — put it on a private mesh and close the public ports.</li>
</ul>
<p>Companion files for this guide — <code>docker-compose.yml</code> and <code>.env.example</code> — live in
the WiLine manifests repo <em>(link coming with the repo)</em>.</p>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="teardown">Teardown<a href="https://development-wec.wiline.com/docs/tutorials/deploy-openclaw-docker-compose/#teardown" class="hash-link" aria-label="Direct link to Teardown" title="Direct link to Teardown" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose down              </span><span class="token comment" style="color:#999988;font-style:italic"># stop + remove containers</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">docker</span><span class="token plain"> compose down </span><span class="token parameter variable" style="color:#36acaa">--rmi</span><span class="token plain"> all </span><span class="token parameter variable" style="color:#36acaa">-v</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic"># also remove the image and volumes</span><br></div></code></pre></div></div>
<p>To remove config/state on the host:
<code>rm -rf ~/.openclaw ~/.openclaw-auth-profile-secrets</code>. To stop billing entirely,
delete the VM from the WiLine portal.</p>]]></content:encoded>
            <category>ai</category>
            <category>self-hosting</category>
            <category>docker</category>
            <category>openclaw</category>
            <category>vps</category>
        </item>
    </channel>
</rss>