<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>Ahmad Hassan — Blog</title>
  <link>https://www.ahmadhassan.pro/blog/</link>
  <atom:link href="https://www.ahmadhassan.pro/blog/rss.xml" rel="self" type="application/rss+xml"/>
  <description>Articles on AI/RAG systems, deployment tooling, and full-stack performance.</description>
  <language>en</language>
  <item>
    <title>Building 70+ Custom Gutenberg Blocks for a NYC Real Estate Brokerage</title>
    <link>https://www.ahmadhassan.pro/blog/bhs-custom-gutenberg-blocks/</link>
    <guid isPermaLink="true">https://www.ahmadhassan.pro/blog/bhs-custom-gutenberg-blocks/</guid>
    <pubDate>Fri, 16 Oct 2026 09:00:00 +0500</pubDate>
    <category>WordPress / Real Estate</category>
    <description>How I built 70+ custom Gutenberg blocks so Brown Harris Stevens&#x27; editors can build full marketing pages without code, and what kept it manageable.</description>
    <content:encoded><![CDATA[<p>A real estate brokerage publishes a lot of pages. New listings, agent profiles, neighbourhood guides, market reports, blog posts, campaign landing pages. And the people who need those pages are marketers and editors, not developers.</p>
<p>At <a href="https://sltech.io/">SLTech</a> I built <strong>BHS Custom Blocks</strong>, a WordPress Gutenberg plugin for <a href="https://www.bhsusa.com/blog">Brown Harris Stevens</a>, one of New York City's best-known residential brokerages. It has more than 70 custom blocks. The goal was simple to say and harder to deliver: <strong>editors should be able to build a full marketing page on their own, and it should still look like Brown Harris Stevens.</strong></p>
<p>This post covers how I think about a block library that size: what goes in it, what keeps it consistent, and what makes editors actually like using it.</p>
<h2>Why custom blocks, not a page builder</h2>
<p>Page builders give editors a lot of freedom. That's the problem. With enough freedom, every page slowly drifts away from the brand: a slightly different blue, a new font size, a button that looks almost right.</p>
<p>Custom blocks flip that around. Each block is a finished piece of the design. The editor picks <em>what</em> goes on the page and fills in the content. The block decides <em>how</em> it looks.</p>
<p>For a luxury brand like BHS, that trade-off is worth it:</p>
<ul>
<li><strong>The brand stays consistent</strong> on every page, no matter who built it.</li>
<li><strong>Pages stay fast</strong>, because each block only loads what it needs. There's no heavy builder framework on top.</li>
<li><strong>Editors can't break the layout</strong> by accident.</li>
<li><strong>It's native WordPress.</strong> Content stays in the standard editor, so there's nothing extra to learn or maintain.</li>
</ul>
<h2>What 70+ blocks actually covers</h2>
<p>Seventy sounds like a lot until you list what a brokerage website needs. The blocks fall into a few families:</p>
<table>
<thead>
<tr>
<th>Family</th>
<th>Examples</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Agents</strong></td>
<td>Agent profiles, team listings, contact cards</td>
</tr>
<tr>
<td><strong>Property</strong></td>
<td>Listing cards, featured properties, property details</td>
</tr>
<tr>
<td><strong>Heroes</strong></td>
<td>Full-width hero banners, campaign headers</td>
</tr>
<tr>
<td><strong>Market data</strong></td>
<td>Market report sections, stats and highlights</td>
</tr>
<tr>
<td><strong>Communities</strong></td>
<td>Neighbourhood and community pages</td>
</tr>
<tr>
<td><strong>Editorial</strong></td>
<td>Article layouts, quotes, image and text sections</td>
</tr>
</tbody>
</table>
<p>Grouping them this way matters for editors too. In the block inserter, they look for "an agent block" or "a hero", not for block number 47.</p>
<h2>Keeping 70 blocks consistent</h2>
<p>The hard part of a big block library isn't building block number 70. It's making sure block 70 still looks and behaves like block 1. A few rules make that possible.</p>
<p><strong>One set of design tokens.</strong> Colours, font sizes, spacing and breakpoints should live in one place, shared by every block. If the brand blue changes, it should change once.</p>
<p><strong>Shared building pieces.</strong> Most blocks are made of the same smaller parts: headings, buttons, image frames, cards. Build those once as shared React components for the editor and shared templates on the front end, and reuse them everywhere.</p>
<p><strong>The same controls everywhere.</strong> If one block has its spacing setting in the sidebar, every block should. Editors learn the pattern once and it works across the whole library.</p>
<p><strong>Clear naming.</strong> Every block gets a predictable name and lives in a clear category. Future developers will thank you, and so will editors searching the inserter.</p>
<h2>Static vs. dynamic blocks</h2>
<p>Gutenberg blocks come in two kinds, and choosing the right one for each block saves a lot of pain later.</p>
<ul>
<li><strong>Static blocks</strong> save their final HTML into the post. They're a good fit for content the editor fully controls, like an editorial section or a quote.</li>
<li><strong>Dynamic blocks</strong> are rendered by PHP on the server each time the page loads. They're the right choice for anything that changes on its own, like listings or agent details, because the page always shows the current data.</li>
</ul>
<p>A useful rule: <strong>if the content can change without an editor touching the page, make the block dynamic.</strong> Otherwise you end up with pages showing a sold property or an agent who left months ago.</p>
<p>Dynamic blocks have one more benefit for a library this size: when you change a block's design, every page using it updates at once. With static blocks, old pages keep the old HTML until someone re-saves them.</p>
<h2>Designing for editors, not developers</h2>
<p>A block is only useful if editors are comfortable using it. What helps:</p>
<ul>
<li><strong>The editor preview should match the live page.</strong> If a block looks different in the editor than on the site, editors stop trusting it.</li>
<li><strong>Give fewer options, with good defaults.</strong> Every setting is a decision the editor has to make. Most blocks should look right the moment they're added.</li>
<li><strong>Use patterns for common pages.</strong> A ready-made layout for "new development launch" or "neighbourhood guide" lets editors start from a page that already works, then swap in their content.</li>
<li><strong>Write labels in plain language.</strong> "Background image", not "bg_media_id".</li>
</ul>
<h2>What I'd tell anyone building a block library</h2>
<ol>
<li><strong>Design the system before the blocks.</strong> Tokens and shared components first. Building blocks one by one without them is how you end up with 70 slightly different buttons.</li>
<li><strong>Default to dynamic for live data.</strong> Listings and people change; pages shouldn't go stale.</li>
<li><strong>Fewer options make happier editors.</strong> Constraints are a feature, not a limitation.</li>
<li><strong>Treat editors as your users.</strong> Watch them build a page. The confusing parts show up in minutes.</li>
</ol>
<p>A good block library feels boring in the best way. Editors open WordPress, build the page they need, and it looks right. That's the whole point.</p>
<p>Need a custom Gutenberg block library or a WordPress build for your team? <a href="/#contact">Get in touch</a>.</p>]]></content:encoded>
  </item>
  <item>
    <title>Gemini 4 Argon Wins 14 of 19 Benchmarks. The Other 5 Matter More.</title>
    <link>https://www.ahmadhassan.pro/blog/gemini-4-argon-benchmarks/</link>
    <guid isPermaLink="true">https://www.ahmadhassan.pro/blog/gemini-4-argon-benchmarks/</guid>
    <pubDate>Thu, 08 Oct 2026 09:00:00 +0500</pubDate>
    <category>AI / Models</category>
    <description>Gemini 4 Argon tops 14 of 19 rows in Google&#x27;s benchmark table. What the numbers say, what they don&#x27;t, and how I&#x27;d pick a model for real work.</description>
    <content:encoded><![CDATA[<p>Every model launch comes with a table like this one, and every table like this one is built to make a point. Google DeepMind's table for Gemini 4 Argon makes its point clearly: a column of blue cells, top to bottom.</p>
<p>I don't train models. I build things on top of them: chatbots, agents, internal tools that real users touch. So when I read a benchmark table, I'm not asking "which model is smartest?" I'm asking "which model should I put behind this feature, and what will break?"</p>
<p>Here's the full table, as Google published it:</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Benchmark</th>
<th>Gemini 4 Argon</th>
<th>GPT-6 Astra</th>
<th>Claude Fable 5.1</th>
<th>Claude Opus 5.5</th>
</tr>
</thead>
<tbody>
<tr>
<td>Knowledge work</td>
<td>Vals Index</td>
<td><strong>68.9%</strong></td>
<td>63.1%</td>
<td>65.8%</td>
<td>67.0%</td>
</tr>
<tr>
<td></td>
<td>AutomationBench (score)</td>
<td><strong>51.3%</strong></td>
<td>41.4%</td>
<td>31.4%</td>
<td>42.5%</td>
</tr>
<tr>
<td></td>
<td>Vals Finance Agent v2</td>
<td><strong>65.4%</strong></td>
<td>53.5%</td>
<td>58.9%</td>
<td>58.6%</td>
</tr>
<tr>
<td></td>
<td>Harvey's Legal Agent Benchmark</td>
<td><strong>19.6%</strong></td>
<td>5.4%</td>
<td>6.7%</td>
<td>3.8%</td>
</tr>
<tr>
<td>Agentic coding</td>
<td>DeepSWE v1.1</td>
<td><strong>77.9%</strong></td>
<td>74.1%</td>
<td>67.4%</td>
<td>74.2%</td>
</tr>
<tr>
<td></td>
<td>FrontierSWE v2</td>
<td>55.0%</td>
<td><strong>65.5%</strong></td>
<td>56.3%</td>
<td>62.3%</td>
</tr>
<tr>
<td></td>
<td>Vibe Code Bench</td>
<td><strong>91.9%</strong></td>
<td>89.6%</td>
<td>90.3%</td>
<td>90.3%</td>
</tr>
<tr>
<td></td>
<td>Terminal-bench 4.0</td>
<td>57.4%</td>
<td>58.2%</td>
<td>57.9%</td>
<td><strong>66.4%</strong></td>
</tr>
<tr>
<td>ML engineering</td>
<td>PostTrainBench</td>
<td>45.3%</td>
<td>44.3%</td>
<td>40.2%</td>
<td><strong>49.3%</strong></td>
</tr>
<tr>
<td>Science and math</td>
<td>Terminal-Bench Science 0.1</td>
<td>57.6%</td>
<td><strong>68.1%</strong></td>
<td>52.6%</td>
<td>63.3%</td>
</tr>
<tr>
<td></td>
<td>LABBench 2</td>
<td><strong>88.8%</strong></td>
<td>85.4%</td>
<td>68.6%</td>
<td>73.1%</td>
</tr>
<tr>
<td></td>
<td>RiemannBench</td>
<td><strong>76.0%</strong></td>
<td>72.0%</td>
<td>65.6%</td>
<td>69.6%</td>
</tr>
<tr>
<td>Long context</td>
<td>GraphWalks, up to 128k (BFS F1)</td>
<td><strong>99.7%</strong></td>
<td>98.7%</td>
<td>91.4%</td>
<td>90.6%</td>
</tr>
<tr>
<td></td>
<td>GraphWalks, 256k to 1M (BFS F1)</td>
<td><strong>84.2%</strong></td>
<td>71.8%</td>
<td>65.0%</td>
<td>66.8%</td>
</tr>
<tr>
<td>Computer use</td>
<td>Agent's Last Exam (pass rate)</td>
<td><strong>39.5%</strong></td>
<td>34.2%</td>
<td>—</td>
<td>38.2%</td>
</tr>
<tr>
<td></td>
<td>OSWorld-2.0 (offline subset, partial score)</td>
<td>69.2%</td>
<td><strong>72.6%</strong></td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Multimodal</td>
<td>Chartography</td>
<td><strong>71.6%</strong></td>
<td>71.0%</td>
<td>46.2%</td>
<td>66.3%</td>
</tr>
<tr>
<td></td>
<td>LVBench</td>
<td><strong>91.7%</strong></td>
<td>87.5%</td>
<td>79.7%</td>
<td>83.7%</td>
</tr>
<tr>
<td>Cybersecurity</td>
<td>CWE-bench v1</td>
<td><strong>68.0%</strong></td>
<td><strong>68.0%</strong></td>
<td>58.0%</td>
<td>67.0%</td>
</tr>
</tbody>
</table>
<p><em>Source: Google DeepMind's Gemini 4 Argon launch materials. Methodology at deepmind.google/models/evals-methodology/gemini-4-argon. Bold marks the best score in each row.</em></p>
<h2>Start with who made the table</h2>
<p>These are Google's numbers, picked and run by Google, for a Google launch. That doesn't make them wrong. It does mean the benchmark list was chosen by the team with the most reason to look good on it, and the competitor scores were measured under Google's setup, not each vendor's own.</p>
<p>So I treat a table like this as a strong hint, not a verdict. The independent leaderboards and, more importantly, your own tests come later.</p>
<h2>Where Argon clearly pulls ahead</h2>
<p>Gemini 4 Argon has the top score, or a share of it, in 14 of the 19 rows. Some of those wins are a point or two. A few are not, and those are the ones I'd pay attention to.</p>
<p><strong>Very long context.</strong> At up to 128k tokens, every model in the table does fine, with all four between 90% and 99.7%. Between 256k and 1M tokens, Argon scores 84.2% and the next best is 71.8%. That's the biggest practical gap in the whole table. If your product needs to reason over a full codebase, a long contract or months of support tickets in one go, this row matters more than any coding benchmark.</p>
<p><strong>Agents doing office work.</strong> AutomationBench (51.3% vs 42.5% for the next model) and Vals Finance Agent v2 (65.4% vs 58.9%) are both about multi-step tasks in business settings. Harvey's legal benchmark is the most striking on paper: 19.6% against single digits for everyone else. But read the absolute number too. Even the winner fails most of those tasks. "Best" and "ready to run unsupervised" are different things.</p>
<p><strong>Charts and video.</strong> On LVBench and Chartography Argon leads, though GPT-6 Astra is close on charts (71.0% vs 71.6%).</p>
<h2>The rows where it doesn't win are more useful</h2>
<p>If you only look at the blue cells, you'd conclude Argon is the best choice for everything. The grey cells tell a more practical story.</p>
<p><strong>Coding is close, and it depends on the kind of coding.</strong> On Vibe Code Bench, all four models land between 89.6% and 91.9%. That's close to a tie, and any of them will handle typical "build me this feature" work. The differences show up in the harder, more specific tests:</p>
<ul>
<li><strong>Claude Opus 5.5</strong> leads Terminal-bench 4.0 by a wide margin: 66.4%, against 57.4% to 58.2% for the others. That benchmark is about working in a real terminal over many steps: running commands, reading output, fixing what broke.</li>
<li><strong>GPT-6 Astra</strong> leads FrontierSWE v2 with 65.5%, ten points above Argon's 55.0%.</li>
<li><strong>Argon</strong> leads DeepSWE v1.1 with 77.9%.</li>
</ul>
<p>Three coding benchmarks, three different winners. To me that's the most honest signal in the table. "Best at coding" isn't one thing anymore.</p>
<p><strong>Science tasks done in the terminal</strong> go to GPT-6 Astra (68.1%), and <strong>ML engineering</strong> goes to Opus 5.5 (49.3%). In both cases Argon trails.</p>
<p><strong>Computer use is thin data.</strong> Fable 5.1 has no scores here, and Opus 5.5 has none on OSWorld. GPT-6 Astra leads OSWorld-2.0, but on an "offline subset" with "partial score", which is a narrower test than the name suggests. I wouldn't pick a model for computer-use agents from these two rows.</p>
<h2>How I'd actually use this</h2>
<p>When I built the RAG chatbot for REConnect, I used Claude Haiku 4.5, which isn't in this table at all. It was the right call, because the job was answering from well-chunked context quickly and cheaply, not solving the hardest benchmark in the world. Most production AI features look like that. The model only needs to be good enough at a narrow job, and fast and affordable at scale.</p>
<p>So here's how I'd turn a table like this into decisions:</p>
<ol>
<li><strong>Match the benchmark to your workload.</strong> If your agent lives in a terminal, Terminal-bench tells you more than the overall win count. If you process huge documents in one pass, the 1M-token row is the one to read.</li>
<li><strong>Run your own evals before switching.</strong> Take 50 to 100 real examples from your product, with known good answers, and run each candidate on them. That will tell you more than any launch chart.</li>
<li><strong>Don't build for one model.</strong> Put a thin layer between your app and the model API, so swapping models is a config change, not a rewrite. With launches this frequent, the "best" model changes every few months.</li>
<li><strong>Route by task if you can.</strong> Nothing stops you from using one model for long-document analysis and another for coding agents. The table practically argues for it.</li>
<li><strong>Keep an eye on what the table leaves out.</strong> There's nothing here about price, speed or rate limits. For a live chat widget, those decide more than a two-point benchmark gap.</li>
</ol>
<h2>So, is Gemini 4 Argon the best model?</h2>
<p>On Google's chosen benchmarks, mostly yes. It wins most rows, and in long context and business-agent tasks it wins by real margins. If those match your workload, it should be first on your list to test.</p>
<p>But the table also shows that the top models are now close on everyday coding, and that each one has areas it's clearly best at. That's good news for anyone building products. It means you can pick by fit, cost and speed, instead of feeling locked into whichever lab has the latest launch.</p>
<p>If you're choosing a model for a product feature and want a second opinion, <a href="/#contact">get in touch</a>. I'm happy to help set up a proper evaluation on your own data.</p>]]></content:encoded>
  </item>
  <item>
    <title>Building a Production RAG Chatbot with OpenAI, Chroma, and Claude</title>
    <link>https://www.ahmadhassan.pro/blog/production-rag-chatbot/</link>
    <guid isPermaLink="true">https://www.ahmadhassan.pro/blog/production-rag-chatbot/</guid>
    <pubDate>Wed, 07 Oct 2026 09:00:00 +0500</pubDate>
    <category>AI / RAG Systems</category>
    <description>How I built REConnect&#x27;s production RAG chatbot with OpenAI embeddings, Chroma Cloud and Claude — chunking, agentic tools, and guardrails.</description>
    <content:encoded><![CDATA[<p>Most chatbot demos stop at "embed some documents, retrieve a few chunks, ask the model." That gets you a demo. Getting to something you can put on a real product's website — where users ask about pricing, coverage, and their own account — takes a lot more.</p>
<p>This post walks through the chatbot I built for <strong><a href="https://reconnectapp.com/">REConnect</a></strong>, a real estate data platform, at <a href="https://sltech.io/">SLTech</a>. It started as a straightforward retrieval-augmented generation (RAG) widget and grew into an agent that can look things up, adapt its tone, and guide people through sign-up.</p>
<h2>The stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Choice</th>
</tr>
</thead>
<tbody>
<tr>
<td>Embeddings</td>
<td>OpenAI embeddings</td>
</tr>
<tr>
<td>Vector store</td>
<td>Chroma Cloud</td>
</tr>
<tr>
<td>Answer model</td>
<td>Claude Haiku 4.5</td>
</tr>
<tr>
<td>Backend</td>
<td>Node.js</td>
</tr>
<tr>
<td>Frontend</td>
<td>Site chat widget</td>
</tr>
</tbody>
</table>
<p>I picked a small, fast model for answering on purpose. In a support widget, latency is part of the experience — and when retrieval does its job, the model mostly needs to read and summarise well, not reason for a long time.</p>
<h2>Step 1: Chunk the source documents</h2>
<p>The knowledge base is the product's own content: <strong>pricing, MLS coverage, and FAQs</strong>. Each type of document has a natural shape, and chunking along that shape matters more than any clever splitting algorithm:</p>
<ul>
<li><strong>FAQs</strong> — one question and its answer per chunk. Never split an answer from its question.</li>
<li><strong>Pricing</strong> — one plan per chunk, with the plan name repeated in the chunk text so it still makes sense on its own.</li>
<li><strong>MLS coverage</strong> — grouped by region/provider so a question like "do you cover X?" lands on a chunk that actually names X.</li>
</ul>
<p>Every chunk carries metadata (source, section, last updated) so I can filter, debug, and re-index one source without touching the rest.</p>
<pre><code class="language-js">// Simplified: index one document's chunks into Chroma
const embeddings = await openai.embeddings.create({
  model: EMBEDDING_MODEL,
  input: chunks.map(c =&gt; c.text),
});

await collection.upsert({
  ids: chunks.map(c =&gt; c.id),
  documents: chunks.map(c =&gt; c.text),
  embeddings: embeddings.data.map(e =&gt; e.embedding),
  metadatas: chunks.map(c =&gt; ({ source: c.source, section: c.section })),
});
</code></pre>
<h2>Step 2: Retrieve, then let the model answer</h2>
<p>For each user message, the backend embeds the query, pulls the <strong>10 most relevant chunks</strong> from Chroma, and passes them to Claude with a system prompt that keeps it grounded:</p>
<ul>
<li>answer only from the provided context and the user's data;</li>
<li>say so plainly when the answer isn't there, and offer a next step (contact support, see pricing) instead of guessing.</li>
</ul>
<p>Ten chunks is a deliberate trade-off. Too few and multi-part questions ("how much is the pro plan and does it cover my MLS?") miss half the answer. Too many and you pay for tokens the model has to wade through. Because the chunks are small and well-shaped, ten fits comfortably.</p>
<h2>Step 3: From answering to acting</h2>
<p>A pure RAG bot can only quote documents. Users kept asking things the documents couldn't answer — "is my listing live?", "which plan am I on?" — so I gave the bot tools:</p>
<ul>
<li><strong>MLS provider lookups</strong> — check coverage against live data rather than a static page.</li>
<li><strong>Account, plan, and listing data</strong> for logged-in users — the bot can answer about <em>their</em> account, scoped strictly to the authenticated user.</li>
<li><strong>Guided sign-up flows</strong> — the bot can walk someone through creating an account, with <strong>explicit confirmation steps</strong> before anything is submitted.</li>
</ul>
<p>The confirmation step is the important part. A model deciding to take an action is fine; a model taking it without the user seeing exactly what will happen is not.</p>
<h2>Step 4: Make it feel right for each user</h2>
<p><strong>Role-aware tone.</strong> An agent, a broker, and a first-time visitor need different answers to the same question. The bot adjusts its tone and level of detail based on who's asking.</p>
<p><strong>Rolling conversation summaries.</strong> Long chats are where costs and confusion creep in. Instead of resending the whole transcript, older turns are periodically folded into a running summary, and only recent turns are sent verbatim. The model keeps the thread of the conversation without the context growing forever.</p>
<h2>Step 5: Production guardrails</h2>
<p>The unglamorous part is what makes it shippable:</p>
<ul>
<li><strong>Rate limiting</strong> per user/session, so one client (or bot) can't run up the bill or degrade the widget for everyone else.</li>
<li><strong>Thumbs-down retry.</strong> When a user marks an answer as unhelpful, the bot retries with that feedback in context instead of just logging it. It's a cheap way to recover a conversation in the moment — and the logged thumbs-downs show exactly where the knowledge base has gaps.</li>
</ul>
<h2>What I'd tell anyone building one</h2>
<ol>
<li><strong>Chunking is the product.</strong> Most "the bot gave a wrong answer" bugs were retrieval bugs, and most retrieval bugs were chunking bugs.</li>
<li><strong>Small model, good context</strong> beats big model, bad context — for latency and for cost.</li>
<li><strong>Tools need confirmations</strong>, and user-data tools need strict scoping to the signed-in user.</li>
<li><strong>Feedback buttons should do something</strong> in the moment, not just feed a dashboard.</li>
</ol>
<p>If you're building something similar, I'm happy to compare notes — <a href="/#contact">get in touch</a>.</p>]]></content:encoded>
  </item>
  <item>
    <title>Prompt-Driven Deployments: Bridging Claude MCP with Coolify via an OAuth Shim</title>
    <link>https://www.ahmadhassan.pro/blog/claude-mcp-coolify-oauth-shim/</link>
    <guid isPermaLink="true">https://www.ahmadhassan.pro/blog/claude-mcp-coolify-oauth-shim/</guid>
    <pubDate>Wed, 07 Oct 2026 09:00:00 +0500</pubDate>
    <category>DevOps / MCP</category>
    <description>How I connected Claude MCP to self-hosted Coolify with a small Node/Express OAuth shim — Supabase sessions, rate limiting and consent.</description>
    <content:encoded><![CDATA[<p>At <a href="https://sltech.io/">SLTech</a> we run client projects on a self-hosted <strong>Coolify</strong> instance. I wanted deployments to be something you could just ask for — "redeploy the staging app", "show me the last build logs" — from Claude, instead of clicking through a dashboard.</p>
<p>The Model Context Protocol (MCP) makes that possible: an MCP server exposes Coolify's operations as tools Claude can call. The catch was authentication.</p>
<h2>The mismatch</h2>
<ul>
<li><strong>Coolify's API</strong> authenticates with a <strong>static bearer token</strong>. One long-lived secret, full API access.</li>
<li><strong>Claude's remote MCP connectors</strong> expect the server to speak <strong>OAuth</strong> — the user is sent through an authorization flow, and Claude receives tokens it can refresh.</li>
</ul>
<p>Handing the Coolify token to every client wasn't an option: there's no consent, no per-session revocation, and a leaked token means full access to the infrastructure. So I built a shim in the middle.</p>
<h2>The shim's job</h2>
<p>A small <strong>Node/Express</strong> service that:</p>
<ol>
<li><strong>Speaks OAuth to Claude</strong> — the authorization and token endpoints Claude's connector expects.</li>
<li><strong>Shows a consent step</strong> before issuing anything, so access is an explicit decision rather than a side effect of knowing a URL.</li>
<li><strong>Proxies MCP requests to Coolify</strong>, attaching the static bearer token <strong>server-side only</strong>. The real token never leaves the shim.</li>
<li><strong>Stores sessions in Supabase</strong>, so they survive redeploys.</li>
<li><strong>Rate-limits</strong> requests per session.</li>
</ol>
<pre><code class="language-text">Claude ──OAuth──▶  Shim (Node/Express)  ──Bearer token──▶  Coolify API
                     │
                     └── sessions &amp; tokens in Supabase
</code></pre>
<h2>Why Supabase for sessions</h2>
<p>Keeping sessions in memory works right up until the shim itself is redeployed — which, on a tool whose whole purpose is deployments, happens a lot. Every redeploy would log everyone out and force them back through the OAuth flow.</p>
<p>Storing sessions and tokens in Supabase (Postgres) avoids that: the process is stateless, and a redeploy is invisible to connected clients. It also gives one place to look up and revoke a session.</p>
<pre><code class="language-js">// Simplified: look up a session by its access token
const { data: session } = await supabase
  .from('mcp_sessions')
  .select('id, expires_at, revoked')
  .eq('access_token_hash', hash(accessToken))
  .single();

if (!session || session.revoked || isExpired(session)) {
  return res.status(401).json({ error: 'invalid_token' });
}
</code></pre>
<p>Storing tokens hashed means a database read alone doesn't hand anyone a usable credential.</p>
<h2>Guardrails</h2>
<ul>
<li><strong>Consent flow protection.</strong> The consent step is where a malicious link could try to trick someone into approving access, so it's protected against being completed by anything other than the user's own deliberate action.</li>
<li><strong>Rate limiting.</strong> An agent in a loop can fire a lot of requests. Per-session limits keep one runaway conversation from hammering the Coolify API.</li>
<li><strong>Least exposure.</strong> The Coolify token lives only in the shim's environment. Clients only ever hold shim-issued tokens that can be revoked individually.</li>
</ul>
<h2>The payoff: deployments by prompt</h2>
<p>With the shim and a custom MCP server in place, deployments across our client projects became <strong>fully prompt-driven</strong>. Redeploys, status checks, and log reads happen from a conversation, and the manual click-through steps are gone.</p>
<h2>Takeaways</h2>
<ol>
<li><strong>When two systems disagree on auth, put a small, boring service between them</strong> rather than weakening either side.</li>
<li><strong>Never forward a master token to clients.</strong> Issue your own revocable tokens and keep the real one server-side.</li>
<li><strong>Persist sessions outside the process</strong> — especially for a service you'll redeploy often.</li>
<li><strong>Rate-limit anything an agent can call.</strong></li>
</ol>
<p>Questions about MCP or Coolify setups? <a href="/#contact">Get in touch</a>.</p>]]></content:encoded>
  </item>
  <item>
    <title>Cutting API Response Times by 40% with Redis, Query Tuning, and Lazy Loading</title>
    <link>https://www.ahmadhassan.pro/blog/cutting-api-response-times-40-percent/</link>
    <guid isPermaLink="true">https://www.ahmadhassan.pro/blog/cutting-api-response-times-40-percent/</guid>
    <pubDate>Wed, 07 Oct 2026 09:00:00 +0500</pubDate>
    <category>Performance</category>
    <description>How Redis caching, query tuning and lazy loading cut API response times by ~40% at The Genius Group — a practical, step-by-step breakdown.</description>
    <content:encoded><![CDATA[<p>At <a href="https://www.genius.co.uk/">The Genius Group</a> I worked on products and marketing infrastructure that saw real, spiky traffic — campaign funnels, lead submission, live data. As usage grew, API latency crept up. Through a combination of <strong>Redis caching, query tuning, and lazy loading</strong>, we brought response times down by roughly <strong>40%</strong>.</p>
<p>None of these techniques is new. The value is in applying them in the right order and measuring as you go.</p>
<h2>1. Measure first</h2>
<p>Before changing anything, find out where the time actually goes. For each slow endpoint I looked at:</p>
<ul>
<li><strong>total response time</strong> — at typical and peak load, not just the average;</li>
<li><strong>time spent in the database</strong> vs. in application code vs. waiting on third-party APIs;</li>
<li><strong>how many queries</strong> a single request triggers.</li>
</ul>
<p>That last number is often the biggest surprise.</p>
<h2>2. Fix the queries</h2>
<p>Caching a slow query just hides it until the cache misses. So queries came first.</p>
<p><strong>Read the query plan.</strong> <code>EXPLAIN ANALYZE</code> shows what the database is really doing:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT id, status, created_at
FROM leads
WHERE campaign_id = $1
ORDER BY created_at DESC
LIMIT 50;
</code></pre>
<p>A sequential scan over a large table for a filter-and-sort like this is the classic sign of a missing index. A composite index matching the filter and sort order turns it into an index scan:</p>
<pre><code class="language-sql">CREATE INDEX idx_leads_campaign_created
  ON leads (campaign_id, created_at DESC);
</code></pre>
<p><strong>Kill N+1 queries.</strong> Loading a list, then querying related data once per row, scales linearly with the list. Fetching related rows in one query (a join, or a single <code>WHERE id = ANY($1)</code> lookup) collapses dozens of round trips into one.</p>
<p><strong>Select only what you use.</strong> <code>SELECT *</code> on wide tables moves data nobody reads.</p>
<h2>3. Cache what's read often and changes rarely</h2>
<p>With queries in good shape, <strong>Redis</strong> took the remaining repeated work off the database. I used the cache-aside pattern:</p>
<pre><code class="language-js">async function getCampaign(id) {
  const key = `campaign:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const campaign = await db.campaigns.findById(id);
  await redis.set(key, JSON.stringify(campaign), 'EX', 300);
  return campaign;
}
</code></pre>
<p>The rules that kept it correct:</p>
<ul>
<li><strong>Cache by access pattern, not by table.</strong> Good candidates are read on almost every request and change rarely — configuration, campaign settings, reference data.</li>
<li><strong>Always set a TTL.</strong> Even with explicit invalidation, a TTL is the safety net against stale data living forever.</li>
<li><strong>Invalidate on write.</strong> When the source record changes, delete its key so the next read repopulates it.</li>
<li><strong>Don't cache per-user, fast-changing data</strong> unless you have a clear invalidation story.</li>
</ul>
<h2>4. Lazy-load the expensive parts</h2>
<p>Some responses were slow because they did everything up front — computing or fetching data that most callers never used. Deferring that work helped on both sides:</p>
<ul>
<li><strong>API side:</strong> return the core payload immediately; expose expensive, rarely-needed details through a separate endpoint or on request.</li>
<li><strong>Frontend side:</strong> load heavy components and below-the-fold data only when needed, so the page becomes usable sooner.</li>
</ul>
<h2>5. Make it stick</h2>
<p>A one-off speed-up decays as features are added. To keep the gains:</p>
<ul>
<li>keep an eye on slow-query logs and endpoint latency after each release;</li>
<li>treat a new N+1 or an unindexed filter as a bug in code review;</li>
<li>keep the infrastructure efficient too — at TGG that included serving over <strong>HTTP/2</strong> with proper SSL configuration on our AWS servers.</li>
</ul>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>What it fixed</th>
</tr>
</thead>
<tbody>
<tr>
<td>Measure</td>
<td>Found where time was really spent</td>
</tr>
<tr>
<td>Query tuning</td>
<td>Missing indexes, N+1 queries, over-fetching</td>
</tr>
<tr>
<td>Redis caching</td>
<td>Repeated reads of rarely-changing data</td>
</tr>
<tr>
<td>Lazy loading</td>
<td>Work done up front that most requests didn't need</td>
</tr>
</tbody>
</table>
<p>Together these brought response times down by about 40%. The order matters: <strong>measure, fix queries, then cache, then defer.</strong></p>
<p>Working on a slow API? <a href="/#contact">Let's talk</a>.</p>]]></content:encoded>
  </item>
</channel>
</rss>
