<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Backend on Tiberiu Petre - Software Engineer</title><link>https://petretiberiu.dev/tags/backend/</link><description>Recent content in Backend on Tiberiu Petre - Software Engineer. Currently exploring backend/systems/infrastructure roles — get in touch: https://petretiberiu.dev/contact/</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 26 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://petretiberiu.dev/tags/backend/index.xml" rel="self" type="application/rss+xml"/><item><title>Sovereign AI Nexus, Part 3: Making It Actually Talk</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</guid><description>&lt;p&gt;Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.&lt;/p&gt;
&lt;h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request"&gt;Postgres, and a client that shouldn&amp;rsquo;t be reborn every request&lt;/h2&gt;
&lt;p&gt;Wiring up Postgres was the easy part: a table for &lt;code&gt;exchanges&lt;/code&gt; (prompt, response, timestamp) and a &lt;code&gt;DatabaseClient&lt;/code&gt; wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&amp;rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.</p>
<h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request">Postgres, and a client that shouldn&rsquo;t be reborn every request</h2>
<p>Wiring up Postgres was the easy part: a table for <code>exchanges</code> (prompt, response, timestamp) and a <code>DatabaseClient</code> wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.</p>
<p>The fix uses FastAPI&rsquo;s <code>lifespan</code> context manager: the client is created once, as a global, when the app starts, and closed once, when it stops. It&rsquo;s a small thing, but the difference between &ldquo;works in a demo&rdquo; and &ldquo;works under real traffic.&rdquo; is very important.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>db_client <span style="color:#f92672">=</span> DatabaseClient()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@asynccontextmanager</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">lifespan</span>(app: FastAPI):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span>
</span></span><span style="display:flex;"><span>    db_client<span style="color:#f92672">.</span>close()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI(lifespan<span style="color:#f92672">=</span>lifespan)
</span></span></code></pre></div><p>One more bug here, easy to miss: the Postgres container&rsquo;s credentials were hardcoded directly in <code>docker-compose.yml</code> (<code>admin</code>/<code>example</code>), while the backend correctly read them from <code>.env</code>. They happened to match, so everything worked right up until someone changes <code>.env</code> and the two sides silently disagree. This was fixed by making the database service read the same environment variables as the backend.</p>
<h2 id="teaching-the-backend-to-delegate">Teaching the backend to delegate</h2>
<p>The interesting part: how does a Python backend running in a container actually talk to Claude Code?</p>
<p>The obvious-looking answer is to mount the host&rsquo;s <code>~/.claude</code> directory into the container but it turns out to be the wrong one. The real mechanism, already proven in a different part of this same infrastructure (a local AI agent I run for other things), is simpler: <code>claude setup-token</code> generates a long-lived OAuth token once and the CLI picks it up from a <code>CLAUDE_CODE_OAUTH_TOKEN</code> environment variable. No mounted credentials directory, no interactive login inside the container.</p>
<p>The rest is a normal Docker build: <code>claude</code> installed via npm (Node&rsquo;s official Alpine build, chosen specifically to avoid repeating a glibc-vs-musl binary compatibility trap I&rsquo;d already hit once with a different tool), a persistent named volume for the CLI&rsquo;s own config so it doesn&rsquo;t reset on every restart, and a minimal, empty working directory. For now, there is no need for a real project, since the backend is using Claude Code as a text-generation engine here, not as a coding agent that needs to touch real files.</p>
<p>Delegation itself is a subprocess call, deliberately async so one slow request doesn&rsquo;t block every other one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>proc <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> asyncio<span style="color:#f92672">.</span>create_subprocess_exec(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;claude&#34;</span>, <span style="color:#e6db74">&#34;-p&#34;</span>, full_prompt,
</span></span><span style="display:flex;"><span>    cwd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/workspace&#34;</span>,
</span></span><span style="display:flex;"><span>    stdout<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>    stderr<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>stdout, stderr <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> proc<span style="color:#f92672">.</span>communicate()
</span></span></code></pre></div><p>It worked on the first real test. Measured round-trip for a simple prompt: about 3.6 seconds for a full CLI process starting up. Worth knowing before this becomes the path for anything latency-sensitive.</p>
<h2 id="the-bugs-that-dont-announce-themselves">The bugs that don&rsquo;t announce themselves</h2>
<p>The endpoint needed conversation history. I&rsquo;ve decided to get the last N exchanges and fed them back in as context so the model isn&rsquo;t starting cold every time. This is where the quiet bugs live.</p>
<p><strong>Bug one, the loud one.</strong> The query to fetch recent exchanges was written as:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> (prompt, response, created_at) <span style="color:#66d9ef">FROM</span> exchanges ...
</span></span></code></pre></div><p>Those parentheses around the column list look harmless. My wrong assumption was to prevent SQL Injection attacks without knowing Postgres reads that as <em>one</em> composite column, not three separate ones. The first test request worked fine, because there was no history yet to fetch. The second one crashed:</p>
<pre tabindex="0"><code>AttributeError: Could not locate column in row for column &#39;prompt&#39;
</code></pre><p>A one-word explanation, once you see it: no parentheses, three real columns.</p>
<p><strong>Bug two, the silent one.</strong> The timestamp was meant to be captured the moment a request arrives right before it goes anywhere near the LLM. The code looked right:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>response <span style="color:#f92672">=</span> ChatResponse(
</span></span><span style="display:flex;"><span>    prompt<span style="color:#f92672">=</span>msg<span style="color:#f92672">.</span>prompt,
</span></span><span style="display:flex;"><span>    response<span style="color:#f92672">=</span><span style="color:#66d9ef">await</span> call_llm(msg<span style="color:#f92672">.</span>prompt),   <span style="color:#75715e"># ~3.6s</span>
</span></span><span style="display:flex;"><span>    created_at<span style="color:#f92672">=</span>datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc),
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>But&hellip; Python evaluates keyword arguments in the order they&rsquo;re written, not by name. The <code>created_at</code> argument is the last one on the page, so it&rsquo;s the last thing evaluated after the multi-second LLM call already finished. The timestamp was quietly measuring the wrong moment. Fixed by capturing it as its own variable, first line of the function, before anything else runs.</p>
<p><strong>Bug three, the one that ate its own fix.</strong> Recent-history queries naturally come back newest-first. Fed straight into a prompt, that reads backwards to the model: the last message first and the first message last. The fix is to reverse the list in Python before building the context, except the first attempt changed the SQL to sort oldest-first <em>and</em> added the Python-side reverse. Confirmed by literally asking the model to recite the conversation back in order and it gave the order correctly, 1 through 4.</p>
<p>None of these three would show up in a five-minute demo. Two of them only show up once there&rsquo;s enough data or enough history for the wrong behavior to matter. It is exactly the kind of bug that&rsquo;s cheap to fix now and expensive to debug later, in production, with a confused conversation to untangle.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>Backend and database talk to each other, and the backend talks to Claude Code. What&rsquo;s still missing is a way for a person to talk to any of it. Task 4 is the chat UI. No deadline on when that happens; it happens when it happens.</p>
<hr>
<p>→ <a href="/series/sovereign-ai-nexus/">Full build series</a> · <a href="/projects/sovereign-ai-nexus/">Project page</a></p>
]]></content:encoded></item></channel></rss>