Showdown Thoughts: The Three-Pass Pattern

Model Showdown Round 5
ended with a leaderboard. Sonnet 4.6 won on the rubric. Opus 4.7 placed
second. Qwen 3.5 contributed almost nothing structural. That’s the
measurement story.

This is the methodology story — what happened after the scores were
revealed.

The Problem With Picking a Winner

The naive workflow after a bakeoff is: pick the best run, merge it to
main, ship it. Winner takes all.

That’s wrong, and Round 5 made it obvious why.

The winning run (Sonnet 4.6) had the best overall rubric score. It also
had a weaker path validator than Opus 4.7, and its orphan-matching logic
would have missed real-world cases that Opus 4.6 caught. The second-place
run (Opus 4.7) had the best validator and the cleanest route structure, but
the worst data source choice — reading from the build-time filesystem
instead of the live GitHub Contents API.

No individual run was what I’d ship. Each one had at least one bad call.
The bakeoff’s real output wasn’t a winner. It was a map.

When 4 of 4 models made the same design choice, that choice was obviously
right. When they diverged — on validation strictness, on data source, on
UX for destructive actions — that divergence was the signal. Those were the
actual design decisions, the ones worth spending judgment on.

The Three Passes

What emerged from Round 5 is a pattern I’ve now run twice and would reach
for again on any feature where the design space is unclear:

Pass 1 — Bakeoff. Run N models (I used 4) on the same prompt in
isolated sessions. Judge blind, before you know which branch is which.
Score against a rubric. The output of this pass isn’t any of the N
implementations — it’s the decision map. You now know which choices are
contested and which are obvious.

Pass 2 — Merge. Write down a merge plan before touching any code: for
each contested layer, which run’s approach wins and why. Then ask an agent
to compose the merged best-of from those inputs. The merge is strictly
better than any individual bakeoff run because it draws on information none
of the bakeoff contestants had — the scored comparison of all four.

For Round 5 the plan looked like this:

Layer Source Why
Path validator Opus 4.7 (Run 1) Only run with 2-segment enforcement + .. block + non-empty checks
Three-tier orphan match Opus 4.6 (Run 2) Only run that noticed exact-match missed real cases like day-four
Type-narrowed body parsing Sonnet 4.6 (Run 3) typeof body === "object" && "path" in body, no as casts
GitHub Contents API Opus 4.6 / Sonnet 4.6 Live state vs. build-time filesystem snapshot
Confirm-modal UX Sonnet 4.6 Best visual polish in the screenshots

Qwen 3.5 contributed nothing structural to this table. The bakeoff said
“skip this one” clearly enough that there was nothing to debate. That’s
useful information too — knowing which pieces to skip is part of the map.

The merge was 13 files changed, +990/-9. One TypeScript error caught and
fixed. Build passed first try after that. Opened as a PR with the heritage
table in the description so future reviewers can trace any decision back to
its source run.

Pass 3 — Polish. The merged feature went live. I opened it against
real production data and spotted four things immediately: truncated
directory names with no tooltip, delete buttons invisible on touch devices,
no bulk delete UI despite the API supporting paths: [], and an orphaned
section header that would show with count 0 after the lone orphan was
deleted.

None of those were predictable before live use. You can’t predict friction
from a code review — you observe it. The polish pass had to come after the
merge because the artifact it was polishing didn’t exist until then.

The polish was 6 files changed, +265/-54 and about 20 minutes of agent
time.

When to Use It

The pattern has a real cost: the bakeoff is N full agent sessions, each
producing a complete implementation that you won’t ship. For Round 5 that
was ~$35 in inference and a few hours of judging.

That’s cheap insurance when the feature has any of these properties:

  • Destructive verbs. Delete, update, payment, permission change. The
    cost of getting validation wrong outweighs the cost of the bakeoff.
  • Multiple defensible architectures. Where should validation live?
    What’s the data source? How does auth thread through? When you genuinely
    don’t know the right answer, a bakeoff shows you the option space.
  • Hard to change later. Database schemas. Public API contracts. Anything
    that will accumulate callers.

It’s overkill for a 20-line UI tweak or a feature with a single obvious
implementation. The signal value of the bakeoff scales with how uncertain
you are about the design.

What I’d Do Differently

Three things I’d change for the next run:

Name the contestant chats before pasting the prompt. All four Round 5
chats showed up as “New Chat” in the Coder API cost summary, which meant
20 minutes of token-volume detective work to figure out which cost belonged
to which run. Five seconds of effort would have prevented that.

Capture per-phase stats. I have clean bakeoff numbers. I don’t have
separate merge or polish numbers — they’re folded into the judging thread.
A lightweight wrapper script around each phase would make the next
iteration measurable end-to-end.

Write the polish friction items down before fixing them. I noticed four
issues and fixed them in one pass, which collapsed the “observed” list and
the “fixed” list into the same moment. Separating them — even by five
minutes — would have made the “what does live-review surface” lesson
sharper for the writeup. And occasionally you’ll notice something that
isn’t worth fixing.

By the Numbers

  • 3 phases: Bakeoff (4 parallel attempts), Merge (1 informed pass), Polish (1 live-review pass)
  • 4 implementations produced in the bakeoff, 0 shipped to main as-is
  • 3 of 4 bakeoff runs contributed at least one structural piece to the merge
  • 13 files changed in the merge pass (+990/-9)
  • 6 files changed in the polish pass (+265/-54)
  • 4 friction items caught in polish that couldn’t have been predicted before live use
  • ~$35.56 inference cost for the bakeoff phase
  • ~45 min bakeoff (parallel), ~30 min merge, ~20 min polish

JavaScript 前端集成贵金属 K 线图:10 分钟快速实现

金融数据可视化中的 K 线图(Candlestick Chart)是前端开发中一个典型难点——既要高效渲染大量历史数据,又要支持实时推送,还要保证缩放、拖拽足够流畅。市面上很多图表库能画折线图、柱状图,但真正为金融场景优化的并不多。

本文将带你10 分钟内跑通一个可交互的贵金属 K 线看板,并给出接入真实行情 API。读完你将掌握:

  • 最适合 K 线图的前端库选型对比
  • 用 Lightweight Charts 快速渲染第一根 K 线
  • 通过 HTTP 拉取历史数据 + WebSocket 接收实时更新
  • 成交量副图、安全代理等工程要点

一、选型对比:哪个图表库更适合 K 线?

库 定位 K 线原生支持 体积 (gzip) 性能特点 授权
Lightweight Charts 专业金融图表 ✅ ~12 KB 金融实时优化,极轻量 需注明版权
ECharts 通用可视化 ✅ ~80-130 KB Canvas 流畅,全能 Apache 2.0
KLineChart 轻量级金融图表 ✅ ~40 KB 5 万条数据 37ms 渲染 开源免费

选型建议:

  • 追求极轻量 + 金融专业性 → Lightweight Charts(本文使用)
  • 需要丰富图表类型 + 中文文档 → ECharts
  • 海量数据 + 极致渲染性能 → KLineChart

二、Lightweight Charts 极速上手(模拟数据版)

2.1 安装

npm install lightweight-charts

或直接使用 CDN:

<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>

2.2 最简代码:5 根 K 线

<div id="chart" style="width: 800px; height: 500px;"></div>

<script>
  const { createChart, CandlestickSeries } = LightweightCharts;

  const chart = createChart(document.getElementById("chart"), {
    width: 800,
    height: 500,
    layout: { backgroundColor: "#ffffff", textColor: "#333" },
  });

  const candlestickSeries = chart.addSeries(CandlestickSeries, {
    upColor: "#26a69a", // 阳线(涨)
    downColor: "#ef5350", // 阴线(跌)
    borderVisible: false,
  });

  candlestickSeries.setData([
    { time: "2024-01-01", open: 2040, high: 2060, low: 2020, close: 2055 },
    { time: "2024-01-02", open: 2055, high: 2080, low: 2045, close: 2070 },
    { time: "2024-01-03", open: 2070, high: 2090, low: 2060, close: 2085 },
    { time: "2024-01-04", open: 2085, high: 2100, low: 2075, close: 2080 },
    { time: "2024-01-05", open: 2080, high: 2095, low: 2065, close: 2075 },
  ]);
</script>

打开页面即可看到带十字光标、可缩放拖拽的 K 线图。

三、完整可运行示例(模拟实时推送)

保存为 index.html 打开,即可看到一个黄金 1 分钟 K 线图,并支持“添加一根新 K 线”来模拟实时推送:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>黄金 K 线图 · 模拟实时行情</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        background: #f5f7fa;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        padding: 20px;
      }
      .card {
        background: white;
        border-radius: 16px;
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
        padding: 20px;
        width: 100%;
        max-width: 1100px;
      }
      .header {
        display: flex;
        justify-content: space-between;
        margin-bottom: 16px;
        align-items: baseline;
      }
      h1 {
        font-size: 1.5rem;
      }
      .price-info {
        font-size: 1.25rem;
        font-weight: 600;
        color: #26a69a;
      }
      .chart-container {
        width: 100%;
        height: 500px;
      }
      .controls {
        margin-top: 16px;
        display: flex;
        gap: 12px;
        justify-content: flex-end;
      }
      button {
        background: #f1f5f9;
        border: none;
        padding: 8px 16px;
        border-radius: 8px;
        cursor: pointer;
      }
      .update-badge {
        font-size: 0.75rem;
        color: #94a3b8;
      }
    </style>
    <script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
  </head>
  <body>
    <div class="card">
      <div class="header">
        <div>
          <h1>黄金 1 分钟 K 线图</h1>
          <div class="update-badge" id="update-time">模拟数据演示</div>
        </div>
        <div class="price-info">
          最新价: <span id="latest-price">0.00</span>
        </div>
      </div>
      <div id="kline-chart" class="chart-container"></div>
      <div class="controls">
        <button id="reset-view">重置视图</button>
        <button id="add-data">添加 1 根模拟 K 线</button>
      </div>
    </div>

    <script>
      (function () {
        // 生成 30 根模拟 K 线
        const generateMockData = () => {
          let basePrice = 2040;
          const data = [];
          const now = new Date();
          for (let i = 30; i >= 1; i--) {
            const minute = new Date(now.getTime() - i * 60 * 1000);
            const timeStr = minute.toISOString().slice(0, 19).replace("T", " ");
            const variation = (Math.random() - 0.5) * 4;
            const open = basePrice;
            const close = +(open + variation).toFixed(2);
            const high = Math.max(open, close) + Math.random() * 2;
            const low = Math.min(open, close) - Math.random() * 2;
            data.push({
              time: timeStr,
              open: +open.toFixed(2),
              high: +high.toFixed(2),
              low: +low.toFixed(2),
              close,
            });
            basePrice = close;
          }
          return data;
        };

        let mockData = generateMockData();
        const chart = LightweightCharts.createChart(
          document.getElementById("kline-chart"),
          {
            width: document.getElementById("kline-chart").clientWidth,
            height: 500,
            layout: { backgroundColor: "#ffffff", textColor: "#1e293b" },
            grid: {
              vertLines: { color: "#e2e8f0" },
              horzLines: { color: "#e2e8f0" },
            },
            timeScale: { timeVisible: true, secondsVisible: false },
          }
        );
        const candlestickSeries = chart.addSeries(
          LightweightCharts.CandlestickSeries,
          {
            upColor: "#26a69a",
            downColor: "#ef5350",
            borderVisible: false,
          }
        );
        candlestickSeries.setData(mockData);
        chart.timeScale().fitContent();

        const updateLatestPrice = () => {
          if (mockData.length) {
            const latest = mockData[mockData.length - 1];
            document.getElementById("latest-price").innerText =
              latest.close.toFixed(2);
            document.getElementById(
              "update-time"
            ).innerHTML = `最后更新: ${latest.time}`;
          }
        };
        updateLatestPrice();

        const addNewCandle = () => {
          const last = mockData[mockData.length - 1];
          const now = new Date();
          const timeStr = now.toISOString().slice(0, 19).replace("T", " ");
          const variation = (Math.random() - 0.5) * 3;
          const open = last.close;
          const close = +(open + variation).toFixed(2);
          const high = Math.max(open, close) + Math.random() * 2;
          const low = Math.min(open, close) - Math.random() * 2;
          const newCandle = {
            time: timeStr,
            open: +open.toFixed(2),
            high: +high.toFixed(2),
            low: +low.toFixed(2),
            close,
          };
          mockData.push(newCandle);
          candlestickSeries.update(newCandle);
          updateLatestPrice();
          chart.timeScale().scrollToRealTime();
        };

        document
          .getElementById("add-data")
          .addEventListener("click", addNewCandle);
        document
          .getElementById("reset-view")
          .addEventListener("click", () => chart.timeScale().fitContent());
        window.addEventListener("resize", () =>
          chart.applyOptions({
            width: document.getElementById("kline-chart").clientWidth,
          })
        );
      })();
    </script>
  </body>
</html>

四、接入真实行情 API

真实场景中需要对接行情数据源。下面以支持 REST + WebSocket 的行情 iTick API 为例,展示接入方法。你可以替换成任何提供类似接口的服务(如自己的后端、第三方数据商等)。

4.1 历史 K 线:REST API 拉取

iTick 的行情 API 提供了期货历史 K 线接口:

GET https://api.itick.org/future/kline?symbol=GC&region=US&kType=1&limit=100

返回格式示例:

{
  "code": 0,
  "data": [
    {
      "t": 1704067200000,
      "o": 2040.5,
      "h": 2060.2,
      "l": 2020.8,
      "c": 2055.3,
      "v": 12500
    }
  ]
}

前端调用时,建议通过自己的后端代理,避免暴露 API 密钥。后端示例(Node.js + Express):

app.get("/api/kline", async (req, res) => {
  const { symbol, region, kType, limit } = req.query;
  const response = await fetch(
    `https://api.itick.org/future/kline?symbol=${symbol}&region=${region}&kType=${kType}&limit=${limit}`,
    { headers: { token: process.env.API_KEY } }
  );
  const data = await response.json();
  res.json(data);
});

前端调用并转换为 Lightweight Charts 所需格式:

fetch("/api/kline?symbol=GC&region=US&kType=1&limit=100")
  .then((res) => res.json())
  .then((data) => {
    if (data.code === 0 && data.data) {
      const klineData = data.data.map((item) => ({
        time: Math.floor(item.t / 1000), // 毫秒时间戳 → 秒
        open: item.o,
        high: item.h,
        low: item.l,
        close: item.c,
      }));
      candlestickSeries.setData(klineData);
      chart.timeScale().fitContent();
    }
  });

注意:不同的 API 返回的时间戳单位可能不同(毫秒/秒),需要根据实际情况转换。

4.2 实时推送:WebSocket 订阅 K 线更新

同样以后端代理 WebSocket 为例,或者直接在前端连接行情网关(需确保 API Key 不暴露)。以下直接展示前端连接某行情 WebSocket 的示例(假设该服务允许前端直接使用临时 token):

let ws = null;

function connectWebSocket() {
  ws = new WebSocket("wss://api.itick.org/future");

  ws.onopen = () => {
    // 发送认证(具体格式依 API 而定)
    ws.send(JSON.stringify({ action: "auth", token: "your_temp_token" }));
  };

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.code === 1 && msg.msg === "Connected Successfully") {
      console.log("连接成功");
    }

    // 认证成功后订阅 K 线频道
    if (msg.code === 1 && msg.resAc === "auth") {
      ws.send(
        JSON.stringify({
          ac: "subscribe",
          params: "GC$US,SI$US", // 格式为:{symbol}${region}
          types: "kline@1", // 可选:depth, quote, tick, kline@1
        })
      );
    }

    // 处理 K 线推送
    if (msg.type === "kline@1") {
      const candle = msg.data;
      candlestickSeries.update({
        time: Math.floor(candle.t / 1000),
        open: candle.o,
        high: candle.h,
        low: candle.l,
        close: candle.c,
      });
    }
  };

  ws.onclose = () => setTimeout(() => connectWebSocket(), 3000);
  ws.onerror = (err) => console.error("WebSocket error", err);
}

connectWebSocket();

安全提醒:任何 API 密钥、token 都不应直接写在前端代码中。上述示例仅为演示逻辑,实际生产环境应通过后端代理或短期令牌方式保护敏感信息。

五、进阶功能:成交量副图与性能优化

5.1 添加成交量柱状图

Lightweight Charts 支持多系列叠加,可轻松添加成交量副图:

const volumeSeries = chart.addSeries(LightweightCharts.HistogramSeries, {
  color: "#26a69a",
  priceFormat: { type: "volume" },
  priceScaleId: "", // 独立右侧轴
});

// 假设从 API 获取的数据中包含成交量 v
volumeSeries.setData(
  apiData.map((item) => ({
    time: Math.floor(item.t / 1000),
    value: item.v,
    color: item.close >= item.open ? "#26a69a" : "#ef5350",
  }))
);

5.2 技术指标(MACD / RSI)

Lightweight Charts 本身不提供指标计算,可以:

  • 使用 ta.js 或 tulind 在前端计算。
  • 将计算结果作为 LineSeries 叠加到图表。
const macdLine = chart.addSeries(LightweightCharts.LineSeries, {
  color: "#FF9800",
});
macdLine.setData(macdValues);

若需要完整的技术指标内置支持,可考虑使用 react-stockcharts 或 KLineChart。

5.3 性能优化建议

  • 数据更新:大量历史数据使用 setData,单根实时 K 线使用 update。
  • 视口管理:不需要一次性渲染几十万根 K 线,利用库自带的数据采样。
  • 窗口 resize:绑定 resize 事件并调用 chart.applyOptions({ width })。

六、总结

本文介绍了如何用 Lightweight Charts 在 10 分钟内搭建一个贵金属 K 线图前端看板,并给出了接入真实行情数据的通用方法(REST 拉取历史 + WebSocket 实时更新)。关键技术点包括:

  • 对比主流 K 线图表库的优缺点,根据场景选型。
  • Lightweight Charts 核心用法:createChart、CandlestickSeries、setData / update。
  • 模拟数据快速原型,以及如何替换为真实 API。
  • 成交量副图、指标叠加等扩展思路。
  • 安全警示:API 密钥绝不能写在前端,必须后端代理。

参考文档:https://docs.itick.org/websocket/future
GitHub:https://github.com/itick-org/

Rust’s Borrow Checker: Translating Intent into Memory Safety

When people start learning Rust, they usually run into the same obstacle: the borrow checker.

I’ve been there, and everyone has. But the truth is this: The borrow checker isn’t against you; it simply translates what you meant into what the computer needs. Once you understand that, Rust’s borrow checker makes a lot more sense.

Let me show you the moment this made sense for me.

The Classic Problem

Here’s a tiny piece of code that looks harmless:


let mut name = String::from("Alice");
let r = &name;

name.push('!'); // this is an error.
println!("{}", r);

Rust says no. And at first, it’s frustrating; it seems like the compiler is being pedantic. But there’s a method to this.

Rust’s Two Rules

Rust has two simple rules:

  1. You can have one mutable reference

  2. OR any number of immutable references

The catch: You can never have both at the same time.

Why? Because if something is being changed, Rust doesn’t want other parts of your code reading a half‑changed value. And that’s all there is to it.

The “Two Doors” Analogy

Imagine your data is a room.

  • An immutable reference is like giving someone a window into the room.
    They can look, but not touch.

  • A mutable reference is like giving someone the key to the room.
    They can move furniture around.

Rust’s rule is simple: You can’t have the window open while someone is rearranging the furniture.

Fixing the Code the Right Way

Here’s our earlier example, but fixed:


let mut name = String::from("Alice");

name.push('!');  // do the mutation first

let r = &name;   // then borrow immutably
println!("{}", r);

Or, if you really need both at once, you can clone. Cloning is not a crime, especially for beginners:


let mut name = String::from("Alice");
let r = name.clone();

name.push('!');
println!("original: {}", name);
println!("copy: {}", r);

A Trick That Helps Every Beginner

If you’re just starting out, follow this habit: If you’re mutating something, finish all your mutations before you borrow it. This one habit eliminates most borrow checker errors you’ll see.

Why Rust Does This (And Why It’s a Good Thing)

Languages like Python, Java, and JavaScript let you mutate data while other parts of your code are reading it. Most of the time, it’s totally harmless. But sometimes, it causes:

  • sneaky race conditions (which usually only occur during production)

  • strange bugs

  • inconsistent state

  • security issues

  • data corruption

Rust prevents all of that, because it’s designed to.

If You’re Learning Rust, Here’s My Advice

Don’t fight the borrow checker. Instead:

  • Borrow when you only need to read

  • Mutate before you borrow

  • Clone when you need independence

  • Trust the compiler’s hints

  • Keep your functions small

  • Return owned data when in doubt

Rust rewards those who take the time to understand the mechanics. The borrow checker is just the first step in that shift in perspective.

Want More Beginner‑Friendly Rust Posts?

I’m working on a series covering:

  • ownership

  • borrowing

  • lifetimes

  • slices

  • structs

  • enums

  • pattern matching

  • error handling

  • async basics

If you want me to cover something specific, or if there’s a specific concept that hasn’t clicked yet, drop it in the comments! I’ll turn the most requested topic into another post.

I Built an AI Receipt Scanner with Gemma 4 — As an SDET with No Dev Background

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

My Honest Starting Point

I want to be upfront about something before diving in: I am an SDET — a Software Development Engineer in Test. My world is test automation, quality assurance, and finding bugs, not building apps from scratch. I have spent the last few weeks learning AI fundamentals, experimenting with different models, trying to understand how this whole ecosystem actually works beneath the surface.
When I came across this hackathon, my first instinct was to scroll past it. This is for developers. But then I thought — why not? The worst that happens is I learn something.
What followed was equal parts confusion, accidental discovery, and a working app I am genuinely proud of.

The Gemini vs Gemma Confusion (I Suspect I’m Not Alone)

My first stop was Google AI Studio. And honestly? It was fantastic for getting ideas off the ground quickly. I built a small app there, got a feel for prompt engineering, and started to understand how multimodal models work.
But there was a problem: every time I tried to use Gemma 4, Google AI Studio kept routing me to Gemini Flash Preview — the latest hosted model. No matter what I selected, it defaulted back to Gemini.
I spent an embarrassing amount of time thinking I was using Gemma 4 when I wasn’t.
That confusion forced me to actually sit down and research the difference. And that is when it clicked:

Gemma is not a smaller Gemini. They share research lineage, but the deployment story is completely different. Once I understood that, everything else fell into place.

What I Built: ReceiptMind

ReceiptMind is an AI-powered receipt scanner that extracts structured data from receipt photos and builds an expense dashboard automatically.
You take a photo of a receipt — any receipt, any store — upload it, and Gemma 4 reads the image and returns:

  • Merchant name
  • Total amount
  • Date
  • Expense category (Food & Dining, Groceries, Transport, Healthcare, Entertainment)
  • Tax amount

No manual entry. No OCR pipeline. No template matching. Just Gemma 4 looking at the image and understanding it.
This started as a feature I wanted to add to a personal finance app I have been quietly building on the side. The hackathon gave me the deadline I needed to actually ship something.

Why Gemma 4 26B MoE — Not the Other Models

This is the question I care most about answering, because I made this choice deliberately.
The Gemma 4 family has four models:

I chose the 26B MoE (A4B) for two specific reasons:

  1. It is the only model in the family with native image input.
    ReceiptMind’s entire value is reading receipt photos. Without multimodal vision, there is no product. The E2B and E4B are text-only. The 31B dense is text-only. Only the 26B MoE can receive an image and reason about what it sees.

  2. Despite 26B total parameters, only 4B activate per token.
    This is the Mixture-of-Experts efficiency. The model routes each token through only the most relevant expert layers — so I get near-31B quality visual reasoning at a fraction of the compute cost. For a hobby project running on a free API tier, this matters enormously.

I also used the 256K context window to pass multiple receipts in a single prompt when generating monthly spending insights — no chunking, no retrieval, just the full history in one shot.

The Tech Stack

Frontend → HTML + Vanilla JavaScript
Backend → Node.js + Express
AI → Gemma 4 26B MoE via OpenRouter (free tier)
Database → Neon Postgres (serverless)
File Upload → Multer (in-memory buffer → base64)

Why OpenRouter?
Google AI Studio kept routing me to Gemini Flash. OpenRouter gave me direct access to google/gemma-4-26b-a4b-it:free with no credit card and no routing surprises. Once I found it, the API worked on the first try.

How It Works — The Architecture

User uploads receipt image
↓
Express backend receives file via multer
↓
Image converted to base64
↓
Sent to OpenRouter → Gemma 4 26B MoE (multimodal)
↓
Gemma reads the image, returns structured JSON
↓
JSON saved to Neon Postgres
↓
Dashboard updates with new receipt + running totals

The Core API Call

Here is the exact call that makes ReceiptMind work. The key is the image_url content block — this is what tells Gemma 4 to look at the receipt image:

const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    model: "google/gemma-4-26b-a4b-it:free",
    messages: [
      {
        role: "user",
        content: [
          {
            type: "image_url",
            image_url: { url: `data:${mimeType};base64,${base64Image}` }
          },
          {
            type: "text",
            text: `Look at this receipt and extract the data. 
            Reply ONLY with JSON:
            {
              "merchant": "store name",
              "amount": 12.50,
              "date": "2026-05-14",
              "category": "Food & Dining",
              "tax": 1.10
            }`
          }
        ]
      }
    ]
  })
});

No OCR library. No preprocessing. No regex parsing of receipt text. Gemma reads the image exactly like a human would and returns clean structured data.

Test Receipts — What Gemma 4 Had to Handle

I tested with 5 real-world receipt types, each designed to stress-test different extraction challenges:

The gas receipt was the toughest — 42.45L @ $1.649/L = $69.96. Gemma extracted both the unit price and total correctly without any hints.

The entertainment receipt had a discount applied before tax, which changes the subtotal calculation. Gemma handled it correctly.

What Broke (And How I Fixed It)

**Problem 1: **The request would hang indefinitely
The free-tier model on OpenRouter can be slow during peak hours. I added a 30-second AbortController timeout so the frontend shows a proper error instead of spinning forever.

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);

Problem 2: Gemma sometimes wraps JSON in markdown code fences
The model would return

json { ... }

instead of raw JSON. Fixed with a one-liner:

const clean = rawText.replace(/json|/g, "").trim();

Problem 3: I had no idea what was failing
As an SDET, my instinct was to add logging everywhere. I added console.log checkpoints at every step (file received → base64 converted → API called → response received → JSON parsed → DB saved). This immediately showed me exactly where things were failing during development.

The Expense Dashboard

After scanning receipts, the dashboard shows:

  • Running total across all receipts
  • Breakdown by category (Food & Dining, Groceries, Transport, etc.)
  • Full receipt log with merchant, amount, date, and category
  • Per-receipt tax tracking (useful for expense reports)

After scanning all 5 test receipts, the dashboard showed a combined $369.70 across 5 categories — exactly matching the manual totals.

What This Means for My Pet Project

ReceiptMind started as one feature of a larger personal finance app I have been building. The plan is to integrate it so users can:

  1. Scan receipts throughout the month
  2. Get AI-generated spending summaries (“You spent 40% more on dining this month”)
  3. Set budget alerts by category
  4. Export expense reports for tax season

The 256K context window is what makes the spending insight feature viable — I can pass the entire month’s worth of receipts in one prompt and ask Gemma to reason across all of them at once.

What I Learned as an SDET Doing This

A few things surprised me:

1. Prompt engineering is just test case design.
Writing a good prompt felt exactly like writing a good test spec — be precise, cover the edge cases, define the expected output format. The skills transferred more than I expected.

2. The model choice matters more than I thought.
I initially assumed any capable model would work. But switching from text-only to multimodal was the difference between having a product and not having one.

3. The confusion between Gemini and Gemma is real.
If you are just getting started, burn this into your memory: Gemma = open weights you run yourself. Gemini = Google’s hosted API. They are different products built from related research.

4. Ship something small and real.
I could have tried to build the full personal finance app. Instead I picked one feature, made it work end-to-end, and learned more in a week than I had in the previous month of reading documentation.

GitHub Repository
🔗 https://github.com/Hema-Nambi/ReceiptMind

Try It Yourself

Requirements:

  • Node.js 18+
  • Free OpenRouter account → openrouter.ai
  • Free Neon database → neon.tech
git clone https://github.com/Hema-Nambi/receiptmind
cd receiptmind
npm install
# Add your keys to .env
node server.js
# Open http://localhost:3000

Built during the Gemma 4 Challenge — May 2026

What AI Tools, MCP Servers, and Skills Actually Do

Diagram showing the flow for a purchase refund AI skill

I remember being very confused when I first heard about an LLM’s ability to request code execution. This feature has been called various names: tool, action, plugin, function. Now the terminology is settling on a single name: tool. However, talking to other developers and reading comments online, I see the confusion has shifted elsewhere. Some argue that, with the introduction of skills by Anthropic, MCP no longer makes sense, and others aren’t convinced of the usefulness of MCP compared to direct tool calls.

The confusion is architectural. Tools, MCP servers, and skills solve different problems at different layers.

AI Tools Are the Model’s Swiss Army Knife

At the most fundamental level, an AI tool is a function that a language model can decide to call. Not a metaphor for “capability” in general, but a specific, callable function with a defined input schema and a predictable output.

When a model needs information or an action it can’t produce from training alone, it generates a structured call to a tool: something like “call get_customer_record with customer_id: 12345.” The host application intercepts that call, runs the actual function, and returns the result to the model. The model then incorporates it and continues.

While the tool calling concept is the same across the different LLMs, each one has its own specifications to invoke tools. Check out OpenAI, Claude, or Gemini documentation for some specific examples.

Tools are what give AI its “hands.” Without them, a model is impressively articulate but isolated from live data and the systems where real work happens. With them, it can search databases, call APIs, send messages, or trigger business logic in response to what a user needs.

The key property to hold onto: a tool should know how to execute one thing. It doesn’t decide when to be called. That judgment belongs to the model, or in more complex systems, to something above it.

Here’s what that looks like in code. Using the Anthropic SDK, you define a tool as a JSON schema: its name, a description the model uses to decide when to invoke it, and the inputs it expects:

import anthropic
​
client = anthropic.Anthropic()

tools = [{
   "name": "get_customer_record",
   "description": "Retrieve a customer's account details by ID",
   "input_schema": {
       "type": "object",
       "properties": {
           "customer_id": {"type": "string", "description": "The unique customer identifier"}
       },
       "required": ["customer_id"]
   }
}]

response = client.messages.create(
   model="claude-opus-4-6",
   max_tokens=1024,
   tools=tools,
   messages=[{"role": "user", "content": "Look up customer C-12345"}]
)

# When the model decides to use the tool, it returns a structured tool_use block
tool_use = response.content[0]
print(tool_use.name)   # get_customer_record
print(tool_use.input)  # {"customer_id": "C-12345"}

A few things to notice:

  • description: This is what the model reads to decide whether to call the tool. A clear, accurate description matters more than the schema itself.
  • input_schema: The structured contract. The model generates arguments that conform to this schema when it decides to invoke the tool.
  • tool_use block: What the model provides when it wants to call a tool. Your application handles the actual execution and passes the result back in the next turn to continue the conversation.

The model never runs the function itself. It produces a structured intent. Your application does the work.

MCP Servers Are the Universal Translator

At first glance, an MCP server might look like a more elaborate way to define tools. In practice, it solves a different problem.

The Model Context Protocol (MCP) is an open standard that introduces a protocol layer between AI applications and the services they connect to. Think of it as USB-C for AI: a universal connector that lets any compliant client communicate with any compliant server, regardless of which model runs underneath.

Without MCP, every AI application builds its own integrations: a custom function schema for one client, a different one for another, yet another for whichever agent framework a team has chosen. The same capability gets re-implemented for each consumer. MCP exists to eliminate that duplication.

Here’s how the architecture works:

  • MCP clients are the components that hosts (i.e., AI applications such as Claude Desktop, a VS Code extension, a custom agent, etc.) instantiate to initiate connections to servers.
  • MCP servers expose three types of primitives:

    • Tools: actions the AI can invoke.
    • Resources: data the AI can read.
    • Prompts: pre-built interaction templates.

The MCP transport layer is equally flexible. The protocol runs over standard I/O for local processes, or HTTP with Server-Sent Events for remote services, using OAuth 2.1 for authentication on remote connections.

What makes MCP structurally different from direct tool definitions is decoupling. A tool definition embedded in a model API call is tightly coupled to that application. An MCP server is portable: the same server works across any compliant client. Build a GitHub integration once, connect it everywhere.

There’s also the matter of bidirectionality: an MCP server can request that the AI client sample from the language model, enabling interactions that go beyond a simple request-response pattern like an API. It’s a more symmetric relationship than direct function calling, which flows strictly from model to function.

AI Skills Direct the Work

Skills occupy a different layer from tools and MCP servers entirely.

Where a tool is a capability and an MCP server is the infrastructure for exposing it, a skill is closer to a recipe: higher-level instructions that tell an AI when and how to use its available capabilities to accomplish a complex goal. A skill doesn’t replace tools. It orchestrates them.

In practice, a skill combines three things:

  • System prompts: Instructions that define a persona and a set of constraints.
  • Logic/Workflows: A series of steps the model should follow.
  • Tool selection strategy: Knowing which tool to pick in a specific sequence to achieve a complex goal.

Consider a customer support scenario. Handling a refund request might involve checking the customer record, looking up the original transaction, verifying return eligibility based on policy, and then either initiating the refund or escalating to a human, as shown in the following diagram:

Diagram showing the flow for a purchase refund AI skill

Each step might invoke a different tool. The skill is the framework that defines the sequence, the conditions, and the decision points. Skills carry the domain knowledge and reasoning structure that raw tool access doesn’t provide.

Anthropic formalized the skills concept for Claude Code, and the format is now an open standard gaining adoption across AI coding tools. The refund scenario above could be expressed as in the following example:

---
name: handle-refund-request
description: "Handle a refund request from the user."
---

You are a customer support agent for Acme Corp.
You have access to: `get_customer_record`, `get_transaction`,
`initiate_refund`, `escalate_to_human`.

When a user requests a refund:

1. Verify the account with `get_customer_record`
2. Retrieve the purchase details with `get_transaction`
3. If the purchase was within 30 days and its amout is less than $500, call `initiate_refund`
4. For amounts over $500, always escalate regardless of purchase date
5. If ineligible, call `escalate_to_human` and explain why
​
Never approve a refund without completing steps 1 and 2 first.

Compare this to the tool definition from the previous section. The tool schema said nothing about when to be called, what order to follow, or what to do when an amount exceeds a threshold. The skill encodes all of that:

  • Available tools: The skill names the specific tools relevant to this task, not everything the agent has access to.
  • Sequence and conditions: Steps 1–5 define the order and the branching logic.
  • Guard rails: “Never approve without completing steps 1 and 2” is judgment the tools themselves can’t encode.

The tools handle execution. The skill handles when, in what order, and under what conditions.

The distinction from tools becomes clearest at the boundary between capability and judgment. A tool knows how to execute a database query. A skill knows when a query is the right mechanism versus, say, asking a user for clarification first. That’s the bridge skills provide: from what an AI can do to what it should do in a given scenario.

Key Differences at a Glance

The distinction between tools, MCP servers, and skills lies in their architectural layer, scope, and security considerations:

  • Definition and scope: A tool is a fundamental capability, defined as a single, specific callable function that the model can invoke via its API schema. Above this is the MCP server, which acts as a standardized protocol server. It exposes a collection of capabilities, including actions, data, and prompts, which are used by AI clients via the MCP protocol. The highest layer is the skill, which is not a capability but a set of instructions guiding AI behavior toward a complex goal, managing a multi-step workflow or domain reasoning through the agent’s orchestration layer.
  • Coupling and portability: Another key difference is portability. Tools are tightly coupled to the application they are defined within. In contrast, MCP servers are portable, designed to work across any compliant client, making them a universal connector. Skills are specific to the particular task or domain they are designed for.
  • Security surface: Each layer presents a distinct security surface. Security for tools focuses on function-level permissions. MCP servers manage risk through protocol-level consent and capability negotiation. Skills introduce behavioral guardrails and decision boundaries, controlling the overall logic and sequence of actions to ensure appropriate conduct.

The following picture summarizes these distinctions:

Table showing the architectural layer, scope, and security considerations for AI tools, MCP servers, and skills.

The relationship is hierarchical: skills direct what to accomplish, tools do the work, and MCP servers are the infrastructure that makes tools available consistently. None of the three replaces the others.

A Security Perspective

Each layer introduces distinct security considerations, and they compound when combined.

At the tool layer, the core risk is over-permission. A tool that can read and write to a database, when the AI only needs to read, creates unnecessary exposure. Prompt injection attacks (where malicious content in retrieved data tricks a model into taking unintended actions) are most damaging when tools have broad permissions. The principle of least privilege applies here as directly as it does anywhere in security engineering.

At the MCP server layer, the attack surface expands because MCP servers are external processes, often network-accessible, and connected to by multiple clients. The risks here include:

  • Tool poisoning: A malicious or compromised server exposes functions that look legitimate but behave maliciously when called.
  • Unauthorized access: Without proper authentication, any client could invoke a server’s capabilities, including sensitive ones.
  • Scope creep: Servers that expose more than intended, by design or misconfiguration, give connected clients broader access than warranted.

The MCP specification addresses this through explicit consent requirements and capability negotiation, but correct implementation is the server author’s responsibility. Remote MCP servers should require OAuth 2.1-based authentication, and permissions granted to any connection should follow the least-privilege principle.

To mitigate risks in both tools and MCP servers, apply the following:

  • Strict Scoping: Never give an AI tool a “God Mode” API key. Use scoped tokens that only allow the specific actions required for that tool.
  • Human-in-the-Loop: For high-stakes tools (like making a payment or deleting data), always require an approval from the user before the application executes the model’s tool call.
  • Input Validation: Treat every argument generated by an LLM as untrusted user input. Validate the types, ranges, and permissions before hitting your backend.

At the skills layer, the risks become behavioral. Skills that don’t account for adversarial inputs, that allow irreversible actions without human confirmation, or that chain too many autonomous steps without a checkpoint are dangerous in a specific way. By the time someone can intervene, the damage is done.

Auth0 for AI Agents addresses several of these challenges at the identity layer. Based on standards, it provides support for user authentication, delegation patterns for acting on a user’s behalf, and asynchronous authorization for scenarios where sensitive operations need human sign-off before proceeding. Auth0’s MCP support, now generally available, provides OAuth 2.1-based authentication for MCP servers.

The underlying principle is familiar from traditional IAM: identity infrastructure doesn’t fundamentally change because the client is an AI, but the patterns for applying it need to account for how agentic systems behave differently.

Three Layers, One System

The shift from AI as a generator to AI as an actor is real, and the architecture that supports it is becoming clearer:

  • Tools give AI a way to act on the world.
  • MCP servers give it a consistent, portable way to access those actions.
  • Skills give it the reasoning framework to act appropriately.

Each layer has its own design considerations, its own security surface, and its own place in a well-structured AI system. Understanding the distinctions is foundational to building systems that aren’t just capable, but trustworthy. As the tooling matures, the teams that understand the layers will be the ones building systems they can actually reason about.

For a closer look at the vulnerabilities that emerge in agentic AI, five critical AI agent security risks is a practical next step. If you’re working through identity for MCP servers specifically, the Auth0 AI documentation covers authentication, delegation, and human-in-the-loop authorization patterns.

Umbraco 18 and OpenAPI: a heads-up for extension developers

Time to upgrade your extension to Umbraco 18?
A friendly heads up, the OpenAPI generation got an overhaul.

Microsoft.AspNetCore.OpenApi is Umbraco’s new library of choice for OpenAPI document generation, replacing Swashbuckle.AspNetCore. It ships with ASP.NET Core and is maintained by Microsoft alongside the framework, which means one fewer third-party dependency to worry about.

The trade-off: if your extension wired up its own OpenAPI document on v17, it won’t compile against v18. The Swashbuckle types it relied on are gone.

Two paths forward:

  1. Migrate to Microsoft OpenAPI. Two options:

    • Use the AddBackOfficeOpenApiDocument extension method: Umbraco’s new helper. What the v18 template uses. Covers most extensions.
    • Use Microsoft’s AddOpenApi method directly: call Microsoft.AspNetCore.OpenApi yourself. For full control.
  2. Keep using Swashbuckle (not recommended). Install Swashbuckle.AspNetCore in your own extension, wire it up, then plug your document into Umbraco’s Swagger UI via AddOpenApiDocumentToUi(...). When rewriting isn’t an option right now.

ℹ️ Note: the OpenAPI spec URL also changed from /umbraco/swagger/{documentName}/swagger.json to /umbraco/openapi/{documentName}.json. If you generate a client from the spec (e.g. via the v17 extension template’s package.json generate-client script), update the URL before regenerating.

Use the AddBackOfficeOpenApiDocument extension method

We added AddBackOfficeOpenApiDocument() in v18 to take the boilerplate out of registering an OpenAPI document. It’s an extension method on IUmbracoBuilder that wires up Umbraco’s sensible defaults so you don’t have to.

Out of the box, the helper registers your document, scopes it to endpoints decorated with [MapToApi(documentName)], applies Umbraco’s naming conventions for schemas and operation IDs, and adds the document to the Swagger UI dropdown at /umbraco/openapi/.

⚠️ Watch out: the first time you call AddBackOfficeOpenApiDocument (or AddOpenApi directly) and build, you’ll hit error CS9137: The 'interceptors' feature is not enabled in this namespace. The Microsoft.AspNetCore.OpenApi source generator (which surfaces your XML doc comments as descriptions in the OpenAPI document, among other compile-time enrichments) propagates through Umbraco’s project reference, but the property that enables it ships only with the Web SDK. Class-library projects don’t get it automatically, so add it to your .csproj:

<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated</InterceptorsNamespaces>
</PropertyGroup>

Minimal usage

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddBackOfficeOpenApiDocument("my-api");
    }
}

This will add and configure a “my-api” OpenAPI document that will display the controllers/endpoints that have the attribute [MapToApi("my-api")].

Typical usage

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddBackOfficeOpenApiDocument("my-api", document => document
            .WithTitle("My API")
            .WithBackOfficeAuthentication());
    }
}

Besides adding a “my-api” OpenAPI document, this will set the document title to “My API” and declare that the document’s operations require backoffice authentication (which is what enables the “Authorize” flow in the Swagger UI).

Builder method reference

Method What it does
WithTitle(string) Sets the document’s Info.Title. Also used as the UI dropdown label unless WithUiTitle is set.
WithUiTitle(string) Overrides only the UI dropdown label, leaving Info.Title untouched.
ExcludeFromUi() Suppresses registration in the Swagger UI document dropdown. Document is still reachable at its JSON URL.
WithBackOfficeAuthentication() Registers the backoffice OAuth2 security scheme on the document and marks operations as requiring authentication. Swagger UI uses this to prompt for login; generated clients use it to send the token.
ConfigureOpenApiOptions(Action<OpenApiOptions>) Adds a configuration callback for the underlying OpenApiOptions. Multiple calls will run in order after Umbraco’s defaults.
WithJsonOptions(...) Sets the JsonOptions used when generating the document’s schema. See Aligning schema serialization below.

Aligning schema serialization

Schema generation needs to use the same JsonOptions your API uses at runtime, otherwise the generated SDKs and the actual payloads will not match. By default, Microsoft.AspNetCore.OpenApi generates schemas using the global HTTP JsonOptions, which means whoever hosts your extension can change them and that affects your schema.

For most backoffice extensions you want the BackOffice named JsonOptions (the same ones Umbraco’s Management API uses). Point the document at them with WithJsonOptions:

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddBackOfficeOpenApiDocument("my-api", document => document
            .WithJsonOptions(Constants.JsonOptionsNames.BackOffice));
    }
}

For this to be meaningful at runtime, your controllers also need to serialize with those same options. The v17 extension template inherits from a plain ControllerBase, which uses the default options instead. Fix that by adding [JsonOptionsName(Constants.JsonOptionsNames.BackOffice)] to your own base controller or to each controller directly.

Without it, the schema reflects backoffice serialization but the actual responses are serialized with the default options, and you’re back to the schema-vs-runtime mismatch.

Going beyond the defaults

The helper is not a closed box. Anything you’d normally do on OpenApiOptions (custom operation/schema/document transformers, your own CreateSchemaReferenceId, a replacement operation ID transformer, etc.) is still available through ConfigureOpenApiOptions. It just runs after Umbraco’s defaults, so you compose on top of them rather than starting from scratch.

For example, registering your own schema transformer:

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddBackOfficeOpenApiDocument("my-api", document => document
            .WithTitle("My API")
            .ConfigureOpenApiOptions(options =>
                options.AddSchemaTransformer<MyExtensionSchemaTransformer>()));
    }
}

Migrating an existing composer

If your extension followed the shape of the v17 dotnet template, the migration is: delete the old composer body and write the AddBackOfficeOpenApiDocument(...) call. Below is the same extension’s composer before and after.

v17 (Swashbuckle)

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddSingleton<IOperationIdHandler, CustomOperationHandler>();

        builder.Services.Configure<SwaggerGenOptions>(opt =>
        {
            opt.SwaggerDoc(Constants.ApiName, new OpenApiInfo
            {
                Title = "My Test Extension Backoffice API",
            });

            opt.OperationFilter<UmbracoExtensionOperationSecurityFilter>();
        });
    }

    public class UmbracoExtensionOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
    {
        protected override string ApiName => Constants.ApiName;
    }

    public class CustomOperationHandler : OperationIdHandler
    {
        // ...
    }
}

v18 (Microsoft OpenAPI + new builder)

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder) =>
        builder.AddBackOfficeOpenApiDocument(
            Constants.ApiName,
            document => document
                .WithTitle("My Test Extension Backoffice API")
                .WithBackOfficeAuthentication());
}

The helper registers UmbracoOperationIdTransformer by default, so the old IOperationIdHandler is no longer needed. If you want custom operation ID logic, register your own through ConfigureOpenApiOptions:

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddBackOfficeOpenApiDocument("my-api", document => document
            .ConfigureOpenApiOptions(options =>
                options.AddOperationTransformer((operation, context, _) =>
                {
                    operation.OperationId = $"{context.Description.ActionDescriptor.RouteValues["action"]}";
                    return Task.CompletedTask;
                })));
    }
}

Use Microsoft’s AddOpenApi method directly

If you want to register a document that isn’t a backoffice document, or you want to start from a clean slate without Umbraco’s defaults, call AddOpenApi yourself. The helper is just a thin wrapper around that method. Umbraco already wires the OpenAPI routing and Swagger UI hosting, so you don’t need to call MapOpenApi or configure endpoints. You only need to register your documents.

For the transformer model itself (how operation/schema/document transformers work), see Microsoft’s Customize OpenAPI documents docs.

For Umbraco’s specifics ([MapToApi] filtering via options.ShouldInclude, schema/operation ID conventions, the public UmbracoSchemaIdGenerator.Generate(type) helper), see the API versioning and OpenAPI docs.

A few things worth remembering:

  • Backoffice authentication: call options.AddBackofficeSecurityRequirements() (from Umbraco.Cms.Api.Management.OpenApi) instead of subclassing BackOfficeSecurityRequirementsOperationFilterBase.
  • Swagger UI registration: documents are no longer auto-listed. Call services.AddOpenApiDocumentToUi(documentName, uiTitle) to surface yours in the dropdown.
  • InterceptorsNamespaces opt-in: same as the helper path. See the Watch out callout at the top of the helper section.

Keep using Swashbuckle

Not recommended. Consider this only if rewriting a complex Swashbuckle setup isn’t feasible right now. Trade-offs:

  • Extra dependency on Swashbuckle.AspNetCore.
  • OpenAPI 3.0 output, whereas the rest of Umbraco’s documents emit 3.1.
  • Extra wiring you maintain yourself.
  • Both libraries depend on Microsoft.OpenApi. A breaking change there that Swashbuckle doesn’t follow leaves your extension incompatible with the rest of the stack.

If you still want to go this route, see the Swashbuckle.AspNetCore README for general setup. The rest of this section is the Umbraco-specific wiring.

1. Install the NuGet package

Add a PackageReference to Swashbuckle.AspNetCore in your extension’s .csproj (or dotnet add package Swashbuckle.AspNetCore).

2. Register Swashbuckle and your document in a composer

In v17, Umbraco called AddSwaggerGen for you and your extension only configured SwaggerGenOptions. In v18 you have to call it yourself:

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddSwaggerGen(opt =>
        {
            opt.SwaggerDoc(Constants.ApiName, new OpenApiInfo
            {
                Title = "My Test Extension Backoffice API",
                Version = "1.0",
            });

            var previousDocInclusion = opt.SwaggerGeneratorOptions.DocInclusionPredicate;
            opt.DocInclusionPredicate((docName, apiDesc) =>
                docName == Constants.ApiName
                    ? apiDesc.ActionDescriptor.HasMapToApiAttribute(docName)
                    : previousDocInclusion?.Invoke(docName, apiDesc) ?? true);

            // any operation filters, schema filters, or other v17 setup you had...
        });
    }
}

3. Host the Swagger JSON middleware via an IUmbracoPipelineFilter

Umbraco no longer calls app.UseSwagger(), so the middleware that serves the Swagger JSON isn’t running. Add it through Umbraco’s pipeline filter, and set the RouteTemplate to match UmbracoOpenApiOptions.RouteTemplate (default umbraco/openapi/{documentName}.json) so the dropdown link resolves.

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.Configure<UmbracoPipelineOptions>(options =>
        {
            options.AddFilter(new UmbracoPipelineFilter("Swashbuckle")
            {
                PostPipeline = app =>
                {
                    var openApiOptions = app.ApplicationServices.GetRequiredService<IOptions<UmbracoOpenApiOptions>>().Value;
                    if (!openApiOptions.Enabled)
                    {
                        return;
                    }

                    var routeTemplate = openApiOptions.RouteTemplate;
                    app.UseWhen(
                        ctx => ctx.Request.Path == routeTemplate.Replace("{documentName}", Constants.ApiName).EnsureStartsWith('/'),
                        branch => branch.UseSwagger(c => c.RouteTemplate = routeTemplate));
                },
            });
        });
    }
}

Scope the middleware to your own document’s URL. Otherwise Swashbuckle intercepts requests for Umbraco’s own documents (Management, Delivery) and returns 404 because they aren’t registered with AddSwaggerGen.

4. Register the document in the Swagger UI dropdown

Register the document in Umbraco’s Swagger UI dropdown:

public class UmbracoExtensionApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddOpenApiDocumentToUi(Constants.ApiName, "My API");
    }
}

TL;DR

The helper is the simplest option for most extensions. AddBackOfficeOpenApiDocument() is what the v18 template uses, gives you Umbraco’s sensible defaults out of the box (naming conventions, Swagger UI registration, JsonOptions alignment), and lets you register an OpenAPI document in a single call.

Calling AddOpenApi yourself is the standard ASP.NET Core approach and works fine. It means more wiring and code to maintain, so reach for it when you need full control (non-backoffice documents, or skipping the Umbraco defaults).

If rewriting really isn’t an option right now, keeping Swashbuckle as your own dependency and plugging into the dropdown via AddOpenApiDocumentToUi(...) will get you through, but plan to revisit when you can.

Further reading

Stop Writing Ugly SQL: A Developer’s Guide to Clean, Readable Queries

We’ve all been there. You open a PR, and the SQL query looks like someone sneezed on the keyboard:

select u.id,u.name,u.email,o.total,o.created_at from users u join orders o on u.id=o.user_id where o.status='completed' and o.created_at >= '2024-01-01' order by o.created_at desc limit 50

It works. It returns the right data. But it’s a nightmare to review, debug, or extend.

SQL readability is not a cosmetic concern — it’s an engineering discipline. Messy SQL leads to logic errors, missed optimizations, and silent bugs that survive code review because nobody wants to untangle the mess.

This guide walks through the conventions, habits, and formatting rules that make SQL a joy to work with.

Why SQL Formatting Matters More Than You Think

Unlike application code, SQL queries often live in multiple places simultaneously — inside ORM raw query methods, migration files, stored procedures, BI tools, and debug logs. A single ugly query might be read by:

  • A backend engineer debugging a slow endpoint
  • A DBA optimizing an index
  • A data analyst trying to understand business logic
  • You, six months from now, at 11pm before a deploy

Consistent formatting makes all of these scenarios less painful.

The Core Rules

1. Capitalize SQL keywords

SQL keywords should be uppercase. Column names, table names, and aliases should be lowercase (or match your schema’s convention).

Bad:

select id, name from users where active = true

Good:

SELECT id, name
FROM users
WHERE active = true

This instantly distinguishes SQL structure from your data model.

2. One clause per line

Every major clause — SELECT, FROM, JOIN, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT — gets its own line. No exceptions.

Bad:

SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.created_at >= '2023-01-01' GROUP BY u.id, u.name HAVING COUNT(o.id) > 5 ORDER BY order_count DESC

Good:

SELECT
    u.id,
    u.name,
    COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2023-01-01'
GROUP BY
    u.id,
    u.name
HAVING COUNT(o.id) > 5
ORDER BY order_count DESC

The second version can be scanned in seconds. The first requires active decoding.

3. Align your columns in the SELECT list

Put each selected column on its own line, indented consistently (2 or 4 spaces — pick one and stick to it):

SELECT
    account_id,
    account_name,
    region,
    plan_type,
    created_at
FROM accounts
WHERE status = 'active'

This makes it trivial to add, remove, or reorder columns during development.

4. Qualify every column name in joins

Once you have more than one table in a query, always prefix columns with their table alias. Never leave a reader guessing which table id or created_at belongs to.

Ambiguous:

SELECT id, name, total, created_at
FROM users
JOIN orders ON users.id = orders.user_id

Clear:

SELECT
    u.id,
    u.name,
    o.total,
    o.created_at
FROM users u
JOIN orders o ON u.id = o.user_id

This also protects you when schema changes add a new column with a conflicting name — your query won’t silently start returning the wrong data.

5. Write meaningful aliases

Single-letter aliases like a, b, x are lazy and harmful. Use short but meaningful abbreviations.

-- Bad
SELECT a.name, b.total
FROM accounts a
JOIN invoices b ON a.id = b.account_id

-- Good
SELECT acc.name, inv.total
FROM accounts acc
JOIN invoices inv ON acc.id = inv.account_id

6. Put JOIN conditions on a separate, indented line (for complex joins)

When join conditions are multi-part or involve expressions, break them out clearly:

SELECT
    p.id,
    p.title,
    c.body AS latest_comment
FROM posts p
LEFT JOIN comments c
    ON c.post_id = p.id
    AND c.created_at = (
        SELECT MAX(created_at)
        FROM comments
        WHERE post_id = p.id
    )
WHERE p.published = true

7. Use CTEs to break up complexity

Common Table Expressions (CTEs) are one of the most underused tools in SQL. Instead of nesting subqueries three levels deep, break the logic into named, readable steps.

Subquery hell:

SELECT avg_revenue, cohort
FROM (
    SELECT cohort, AVG(revenue) AS avg_revenue
    FROM (
        SELECT
            DATE_TRUNC('month', first_purchase_date) AS cohort,
            SUM(order_total) AS revenue
        FROM (
            SELECT u.id, MIN(o.created_at) AS first_purchase_date, o.total AS order_total
            FROM users u
            JOIN orders o ON u.id = o.user_id
            GROUP BY u.id, o.total
        ) user_orders
        GROUP BY cohort
    ) cohort_revenue
    GROUP BY cohort
) final
ORDER BY cohort

CTE version:

WITH user_first_purchase AS (
    SELECT
        u.id AS user_id,
        MIN(o.created_at) AS first_purchase_date
    FROM users u
    JOIN orders o ON u.id = o.user_id
    GROUP BY u.id
),

cohort_revenue AS (
    SELECT
        DATE_TRUNC('month', ufp.first_purchase_date) AS cohort,
        SUM(o.total) AS revenue
    FROM user_first_purchase ufp
    JOIN orders o ON ufp.user_id = o.user_id
    GROUP BY cohort
),

cohort_averages AS (
    SELECT
        cohort,
        AVG(revenue) AS avg_revenue
    FROM cohort_revenue
    GROUP BY cohort
)

SELECT
    cohort,
    avg_revenue
FROM cohort_averages
ORDER BY cohort

The CTE version is longer but infinitely more debuggable. You can run each CTE independently to validate intermediate results. This matters enormously in production debugging.

8. Comment non-obvious logic

SQL is often used for business logic that isn’t apparent from the data alone. Comment that context directly in the query.

SELECT
    customer_id,
    SUM(order_total) AS lifetime_value
FROM orders
WHERE
    -- Exclude test orders created by the QA team's shared account
    customer_id != 9999
    -- Only count orders after the platform migration (older data is unreliable)
    AND created_at >= '2022-06-01'
GROUP BY customer_id
HAVING SUM(order_total) > 0

Future you will be grateful.

Refactoring a Real Query: Before and After

Here’s a realistic before/after from a production analytics query:

Before:

select u.id,count(distinct s.id) as sessions,count(distinct e.id) as events,max(s.started_at) as last_seen from users u left join sessions s on s.user_id=u.id and s.started_at>now()-interval '30 days' left join events e on e.session_id=s.id and e.type not in ('pageview','heartbeat') where u.plan='pro' and u.deleted_at is null group by u.id having count(distinct s.id)>0 order by sessions desc limit 100

After:

SELECT
    u.id,
    COUNT(DISTINCT s.id)  AS sessions,
    COUNT(DISTINCT e.id)  AS events,
    MAX(s.started_at)     AS last_seen
FROM users u
LEFT JOIN sessions s
    ON s.user_id = u.id
    AND s.started_at > NOW() - INTERVAL '30 days'
LEFT JOIN events e
    ON e.session_id = s.id
    -- Exclude low-signal event types from engagement count
    AND e.type NOT IN ('pageview', 'heartbeat')
WHERE
    u.plan = 'pro'
    AND u.deleted_at IS NULL
GROUP BY u.id
HAVING COUNT(DISTINCT s.id) > 0
ORDER BY sessions DESC
LIMIT 100

Same query. Completely different readability. The second version makes the intent, filters, and join logic obvious at a glance.

Tooling: Format First, Then Review

If you’re working with legacy SQL or reviewing someone else’s query, formatting it first before reading it is one of the highest-ROI habits you can build. A well-formatted query reveals its structure — and its bugs.

For quick formatting without leaving your browser, this SQL formatter handles most SQL dialects and produces consistently indented, readable output in one click. It’s especially useful when you inherit a minified query from a log file or a BI tool export and need to understand it quickly.

For integrating formatting into your dev workflow, the sql-formatter npm package is excellent — it supports PostgreSQL, MySQL, SQLite, BigQuery, and more, and you can hook it into Prettier or a pre-commit check.

Conventions Worth Standardizing Across Your Team

If you’re working in a team, agree on these and write them down:

Convention Recommended Default
Keyword casing UPPERCASE
Identifier casing snake_case (match your schema)
Indentation 4 spaces
Alias style Short abbreviation, not single letter
Column per line Always in SELECT with 3+ columns
CTE preference Always over 2+ levels of subquery nesting

Enforcing these via a linter (e.g. SQLFluff) rather than hoping for consistency is even better — SQLFluff supports dozens of dialects and integrates with CI pipelines cleanly.

Quick Reference Cheat Sheet

-- ✅ Correct structure
WITH cte_name AS (
    SELECT
        column_one,
        column_two,
        column_three
    FROM table_name tn
    JOIN other_table ot ON tn.id = ot.table_id
    WHERE tn.some_condition = true
)

SELECT
    c.column_one,
    c.column_two,
    c.column_three,
    COUNT(t.id) AS related_count
FROM cte_name c
LEFT JOIN third_table t ON c.column_one = t.foreign_key
GROUP BY
    c.column_one,
    c.column_two,
    c.column_three
ORDER BY c.column_one ASC
LIMIT 1000

Final Thoughts

SQL style isn’t about personal preference — it’s about the next person (including yourself) who has to read, debug, or extend your query under pressure. Clean SQL is a form of documentation.

The conventions here are not the only valid choices. What matters is consistency. Pick a style, document it, and automate enforcement where possible.

If you have conventions or habits that have saved you in production, drop them in the comments — I’d love to see what others have standardized.