<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.ombulabs.ai/blog/rss.xml" rel="self" type="application/atom+xml" /><link href="https://www.ombulabs.ai/blog/" rel="alternate" type="text/html" /><updated>2026-09-03T11:25:50-04:00</updated><id>https://www.ombulabs.ai/blog/rss.xml</id><title type="html">OmbuLabs.ai</title><subtitle>Custom AI Solutions</subtitle><author><name>OmbuLabs</name></author><entry><title type="html">When Does Your Business Need an AI Agent?</title><link href="https://www.ombulabs.ai/blog/when-does-your-business-need-an-ai-agent.html" rel="alternate" type="text/html" title="When Does Your Business Need an AI Agent?" /><published>2026-09-03T08:57:01-04:00</published><updated>2026-09-03T08:57:01-04:00</updated><id>https://www.ombulabs.ai/blog/when-does-your-business-need-an-ai-agent</id><content type="html" xml:base="https://www.ombulabs.ai/blog/when-does-your-business-need-an-ai-agent.html"><![CDATA[<p>You’ve probably heard the word “agent” in every AI pitch you’ve sat through this year, from vendors, from consultants, maybe from your own team. It gets used to describe everything from a customer service chatbot to a fully autonomous system nobody’s watching, which means the word alone doesn’t tell you much.</p>

<p>We build these systems for a living, and even we don’t reach for an agent by default. In this article, you’ll learn what an AI agent actually does differently from a chatbot, what that looks like on a real project, and how to tell if your business has a task that’s actually a good fit for one.</p>

<!--more-->

<h2 id="what-an-ai-agent-actually-does">What an AI Agent Actually Does</h2>

<p>Most of what gets called “AI” in a business tool today is a single request and a single response: you ask a question, a model answers it, done. A chatbot on a website works this way. So does a tool that summarizes a document or drafts an email for a person to review. One step, and a person decides what happens next.</p>

<p>An agent is built around a loop instead of a single step. It takes on a task, works out what it needs to do first, does it, looks at the result, and decides what to do next, on its own, for as many steps as the task takes. If a step doesn’t work, it doesn’t just fail, it can recognize that and try something different. The “agent” part isn’t the model getting smarter, it’s the system giving that model a task, tools to act with, and room to keep going without someone approving every single move.</p>

<p>That last part, not needing a person at every step, is also exactly why an agent takes more work to get right than a single request does. Every decision it makes on its own is a decision that can go wrong on its own too.</p>

<h2 id="what-this-looks-like-in-practice">What This Looks Like in Practice</h2>

<p>Here’s what this looked like when we built one for real. A client needed event listings, dates, and prices pulled from hundreds of different partner websites into one searchable list. Every site is built differently, so there’s no single rule that finds “the event title” or “the ticket price” across all of them, and writing custom logic for each site by hand wasn’t realistic at that scale.</p>

<p>So we built a system where the AI works out where that information lives on each site and tries it. Its first guess is wrong roughly a third to nearly half the time, sites are messier than they look. What makes this workable is a loop, not a smarter guess: a person checks what the AI pulled, flags what’s wrong, and the AI tries again with that feedback, for a few rounds, before anything is treated as finished.</p>

<p>That’s the shape of an agent doing real work: not one clever guess, but a loop that catches and fixes its own mistakes, with a person checking in along the way instead of approving every single step. (If you want the engineering details behind this one, we wrote those up in our <a href="https://www.ombulabs.ai/blog/ai-driven-scraping">Behind an AI-Driven Web Scraping System</a> post.)</p>

<h2 id="when-an-agent-is-the-right-call">When an Agent Is the Right Call</h2>

<p>An agent earns its complexity when a task is too varied to write one rule for, happens often enough that a person doing it by hand doesn’t scale, and getting it wrong occasionally is something you can catch and fix rather than something that quietly costs you money or a customer. The project above fits all three: hundreds of different sites, too many to hand-code one at a time, and a wrong guess just gets flagged and retried instead of going out the door.</p>

<p>That’s a real bar to clear. Not every business task meets it, and we’ve written elsewhere about <a href="https://www.ombulabs.ai/blog/finding-the-right-problems-to-solve-with-ai">how to tell whether AI is worth pointing at a task at all</a> before you get as far as choosing a specific tool like an agent.</p>

<p>The honest answer to “should we use an agent for this” is the same as it is for most tools: it depends on the task, not on how often the word shows up in a pitch deck this year.</p>

<h2 id="conclusion">Conclusion</h2>

<p>An agent isn’t a smarter chatbot, it’s a system that takes a task, works through it in steps, and catches its own mistakes along the way, with a person checking in rather than approving every move. On a real project, that looked like hundreds of different websites and a loop that got things wrong on the first try roughly a third to nearly half the time, then corrected itself before anything shipped. Whether it’s worth building one for your business comes down to the task in front of you, not the word itself.</p>

<p>None of that makes an agent free to run. It still needs someone watching how it performs, and someone willing to say a simpler tool would do the job better, when that’s true.</p>

<p>Not sure if your business has a task that’s actually a fit for an agent? <a href="https://www.ombulabs.ai/our-services#service-details-card-ai_agents">Talk to our AI Agents team</a> and we’ll help you figure out if it’s worth building one.</p>]]></content><author><name>mateuspereira</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[AI agents are everywhere in vendor pitches right now, but the word alone doesn't tell you much. Here's what an agent actually does differently from a chatbot, grounded in a real project, and how to tell if your business has a task that's actually a fit for one.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/when-does-your-business-need-an-ai-agent.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/when-does-your-business-need-an-ai-agent.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">When Your AI Provider Says No</title><link href="https://www.ombulabs.ai/blog/when-your-ai-provider-says-no.html" rel="alternate" type="text/html" title="When Your AI Provider Says No" /><published>2026-09-01T11:30:00-04:00</published><updated>2026-09-01T11:30:00-04:00</updated><id>https://www.ombulabs.ai/blog/when-your-ai-provider-says-no</id><content type="html" xml:base="https://www.ombulabs.ai/blog/when-your-ai-provider-says-no.html"><![CDATA[<p>You rolled out an AI tool to your team a year ago. Maybe it was ChatGPT Team for the office, Copilot for the two people who write code, or an AI feature that showed up inside the CRM you already pay for. Whatever it was, it stuck. People use it every day, and if it disappeared tomorrow, someone would notice before lunch.</p>

<p>On August 29, 2026, OpenAI <a href="https://openai.com/index/our-decision-on-cursor-following-its-acquisition-by-spacex/">told Cursor</a>, one of the most widely used AI coding tools, that Cursor’s access to OpenAI models ends on November 12. There was no outage, no price change, and no wrongdoing on Cursor’s side. Cursor had just been bought by SpaceX, and OpenAI decided it could not trust the new owner to honor its terms. Cursor’s CEO wrote that they had “trusted their platform to be neutral infrastructure for our business.”</p>

<p>That sentence is why this post exists. Cursor is a good example, and this post is not really about Cursor. It is about what it means to run a business on tools whose provider can decide, for reasons that have nothing to do with you, that you can’t use them anymore. We ran the exercise on ourselves at OmbuLabs: where AI actually sits in the business, which company is underneath each tool, what stops if that company says no, and which of those gaps are worth paying to close. What follows is that exercise.</p>

<!--more-->

<h2 id="what-happened-to-cursor-and-what-didnt">What happened to Cursor, and what didn’t</h2>

<p>The timeline is short. SpaceX agreed to buy Cursor in June for about $60 billion in stock, and the deal closed on August 14. Fifteen days later, OpenAI announced that it would stop serving its models to Cursor on November 12, giving Cursor roughly ten weeks and the option to pick an earlier date. OpenAI’s stated reason was earlier disputes with Elon Musk’s companies, including the admission that xAI had trained on OpenAI outputs.</p>

<p>The way Cursor used those models did not change. Its owner did, and OpenAI made a decision about that owner. OpenAI’s <a href="https://help.openai.com/en/articles/20001506-using-openai-models-in-cursor">help center</a> lists three ways to keep using its models in Cursor after that, your own API key, OpenAI’s Codex extension, or a gateway such as Amazon Bedrock or Azure, and all three mean the thing you were paying Cursor for is now something you set up and pay for yourself.</p>

<p>Cursor’s CEO, Michael Truell, <a href="https://www.cnbc.com/2026/08/29/openai-cursor-spacex-model-access.html">said</a> OpenAI models accounted for about 5% of Cursor’s traffic. Most of its users were already on Anthropic’s Claude or Google’s models, both of which keep working, and Anthropic <a href="https://www.engadget.com/2246969/openai-pull-its-models-from-cursor-due-to-spacexai-acquisition/">said publicly</a> it would add capacity for Claude in Cursor. Cursor will be fine.</p>

<p>It is worth being honest about why. Cursor did not spread its usage across model providers as a continuity strategy. It offered several models because its users wanted the choice, and those users drifted toward Claude because they preferred it for coding. The resilience was real, but nobody planned it, and that is the part you can’t count on for your own business.</p>

<h2 id="the-other-ways-a-provider-says-no">The other ways a provider says no</h2>

<p>Cursor is not the first coding tool this has happened to, and OpenAI is not the only provider that does it. In June 2025, while OpenAI was reportedly buying Windsurf, Anthropic <a href="https://techcrunch.com/2025/06/03/windsurf-says-anthropic-is-limiting-its-direct-access-to-claude-ai-models">cut off</a> nearly all of Windsurf’s direct access to Claude models with, in Windsurf’s words, less than five days of notice. Windsurf said it had wanted to pay for the full capacity. It is the same shape as Cursor, with a different provider doing the cutting and about a tenth of the notice.</p>

<p>Acquisitions are one trigger, and geography is another. In June 2024, OpenAI <a href="https://www.theregister.com/2024/06/25/openai_unsupported_countries/">emailed</a> developers that it would block API traffic from countries it doesn’t support starting July 9, about two weeks out. In September 2025, Anthropic <a href="https://www.anthropic.com/news/updating-restrictions-of-sales-to-unsupported-regions">updated its terms</a> to refuse service to any company more than 50% owned by entities headquartered in places like China, wherever that company actually operates. A business in Singapore or Germany with the wrong parent company lost access without moving anywhere.</p>

<p>Retirements are the quiet version. On January 29, 2026, OpenAI <a href="https://openai.com/index/retiring-gpt-4o-and-older-models/">announced</a> it would remove GPT-4o and three other models from ChatGPT on February 13, noting that only 0.1% of users still picked GPT-4o on a given day. Business and Enterprise plans kept it inside custom GPTs until April 3. If your team built a workflow around one model’s particular behavior, a retirement breaks it as thoroughly as a ban does, just more politely.</p>

<p>Across all of these, the notice ranged from ten weeks to five days, none of the affected customers had done anything wrong, and both of the providers most businesses use today, including the one we use most, have done it at least once.</p>

<h2 id="the-inventory">The inventory</h2>

<p>The exercise itself is not complicated, and a spreadsheet is enough. Four columns: the tool, the company whose model runs underneath it, who in the business uses it, and what stops if it goes away. Here is what a first pass might look like for a small accounting firm, made up for the example:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Provider underneath</th>
      <th>Who uses it</th>
      <th>What stops</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ChatGPT Team, 12 seats</td>
      <td>OpenAI</td>
      <td>Everyone</td>
      <td>Drafting client emails and summarizing documents goes back to by hand</td>
    </tr>
    <tr>
      <td>Support chatbot on the website</td>
      <td>Unknown, the vendor won’t say</td>
      <td>Clients</td>
      <td>The chatbot goes dark and phone and email absorb it</td>
    </tr>
    <tr>
      <td>“AI assistant” inside the CRM</td>
      <td>OpenAI, per the vendor’s docs</td>
      <td>Sales</td>
      <td>Call summaries stop, and nobody remembers how they were written before</td>
    </tr>
    <tr>
      <td>Meeting notetaker</td>
      <td>Anthropic, per the vendor’s subprocessor list</td>
      <td>Partners</td>
      <td>Notes revert to a person taking them</td>
    </tr>
    <tr>
      <td>Automation step that sorts inbound email</td>
      <td>OpenAI</td>
      <td>Office manager</td>
      <td>Inbox triage goes manual, and someone notices three days later</td>
    </tr>
  </tbody>
</table>

<p>The first column is easy, and the third and fourth are mostly a matter of asking around. The second column is the one people skip, because for most tools the provider underneath is nowhere on the invoice. Each of those tools is somebody else’s product, calling a model provider you never chose, and it is not unusual for several of them to be calling the same one, which means a single “no” can take out three rows at once.</p>

<p>Finding out is usually possible. Most software vendors publish a list of subprocessors, the other companies they send your data to, because privacy regulations require it, and the model provider tends to appear there. Failing that, the vendor’s documentation or a direct question to your account manager will do. It is the same question we put on the list for safety reasons in <a href="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions">The Guardrail Question to Ask Any AI Vendor</a>, asked this time for continuity reasons.</p>

<p>The one answer to be wary of is “we use multiple providers.” It is a good sign, but ask which provider handles the feature you actually depend on. Cursor also used multiple providers, and its OpenAI users still lost their model.</p>

<h2 id="what-breaks-and-what-a-fallback-costs">What breaks, and what a fallback costs</h2>

<p>With the fourth column filled in, most rows sort themselves into one of three piles. Some are an annoyance: people lose a shortcut and go back to doing the thing the way they did it eighteen months ago. Some slow a team down in a way that shows up in the numbers, a support queue that grows or a sales team that stops logging calls. A few stop something that makes money or keeps you compliant, like a chatbot that is the only after-hours channel or a document pipeline a client deadline depends on. Be strict about which pile each row belongs in, because the temptation is to put everything in the third one, and that is how you end up paying for fallbacks nobody will use.</p>

<p>For the first pile, do nothing. Diversifying has a cost, and for a tool people would grumble about losing and then get on with their day, the cost is not worth it. For the second pile, the cheapest insurance is usually not a second provider. It is making sure the things that live inside the tool also live somewhere else: the prompts and instructions people have refined over months, the kind of shared knowledge Fiona described in <a href="https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people">AI for Small Business Starts With People</a>, the document templates, the custom assistants someone built, exported into a folder you control. A workaround like bringing your own API key gives you the model back, but whatever was built inside the tool around it, saved conversations, custom assistants, months of refined instructions, does not move with you.</p>

<p>For the third pile, a real second option is justified, and it is usually cheaper than it sounds: a second seat with another provider for the two or three people who depend on the tool most, tested once a quarter so someone knows it works, and a written version of the manual process, kept current. What is expensive is running two providers for everything at once, because the outputs differ and someone has to check both. We would not recommend that for a business without an engineering team, and even for us it only makes sense on a few rows.</p>

<h2 id="our-own-inventory">Our own inventory</h2>

<p>We are a software company, so our list looks different from the accounting firm’s, but the columns are the same.</p>

<p>The biggest row is Claude Code, from Anthropic. Most of our engineers use it daily, and our blog writing process, the one we described in <a href="https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process">AI Assistant for Our Blog Writing Process</a>, is a set of Claude Code skills on top of a server we built. If Anthropic said no tomorrow, engineering would slow down noticeably for a few weeks and the blog workflow would have to be rebuilt for a different agent. That puts it in the second pile, and we have decided to accept the dependency. The parts we own, the server and the skill files, are plain code and plain text that other agents can read, so what we would actually lose is the agent itself and the habits built around it, and no second provider gives us those back. What we do instead is keep the skills and the server portable and avoid building on features only Claude Code has.</p>

<p>The same server uses Voyage AI for embeddings, the numbers that make the search over our posts work, as we covered in <a href="https://www.ombulabs.ai/blog/blog-mcp-server-fastmcp-pgvector">A Blog MCP Server with FastMCP and pgvector</a>. Voyage was <a href="https://www.mongodb.com/company/newsroom/press-releases/mongodb-announces-acquisition-of-voyage-ai">bought by MongoDB</a> in February 2025, before we chose it, so that row already carries an ownership change. If it went away, re-embedding a few hundred posts with another provider is a script and an afternoon. That is the first pile.</p>

<p>The newsletter assistant we wrote about in 2025 ran on OpenAI models. It has since become a marketing assistant, folded into the same platform as the blog server, and it runs on Anthropic models now. The row moved from one provider to the other without anyone treating it as a provider decision, and our list is more concentrated on Anthropic than it was a year ago.</p>

<p>Two smaller rows point elsewhere. Our <a href="https://www.ombulabs.ai/blog/multi-modal-card-generator.html">Rails Superhero Card Generator</a>, the small app that turns a photo and a few skills into a superhero card, uses Google’s Gemini image models. A report generation tool we use now and then runs on OpenAI models. Both would be missed for about as long as it takes to notice, and neither gets a fallback.</p>

<p>The rows we expected to be the hardest to fill, the AI features inside the software we pay for, turned out to be mostly empty. GitHub, Slack, and Jira all ship AI features, and we barely use them. Our AI usage is our own tooling and Anthropic’s Claude products, which makes our inventory shorter than most and more concentrated than most. Client work stays off this list, for their sake rather than ours.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Cursor lost a provider it had trusted to be neutral, and it came out fine because its users had already spread themselves across three providers, for reasons that had nothing to do with resilience. The other cases we looked at came with notice periods from ten weeks to five days, both of the big providers were on the giving end at least once, and none of the customers involved had done anything to deserve it.</p>

<p>The exercise that follows from that is a four-column inventory, with the provider column being the one worth the effort, a sort into three piles, and a fallback only for the rows in the third pile. For our own list, the result was one accepted dependency, several rows that need nothing, and a concentration on a single provider that is higher than it was a year ago and that we are choosing to live with for now.</p>

<p>The caveat is that an inventory is a snapshot. Ours changed in under a year without anyone deciding to change it, as one tool became another and switched providers on the way. Providers change their terms faster than anyone updates a spreadsheet, so the useful habit is a short review whenever something new gets rolled out, or whenever a headline like Cursor’s shows up.</p>

<p>Not sure which provider sits under the tools your team uses every day, or which ones would actually hurt to lose? We can help you find out, so <a href="https://www.ombulabs.ai/contact">let’s talk</a>.</p>]]></content><author><name>abizzinotto</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[OpenAI is pulling its models from Cursor over a change of ownership, not a breach or a bill. We ran the exercise on ourselves: where AI sits in the business, which provider is underneath each tool, what stops if they say no, and which gaps are worth paying to close.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/when-your-ai-provider-says-no.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/when-your-ai-provider-says-no.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Traces to Insights: Evaluating LLM Apps</title><link href="https://www.ombulabs.ai/blog/traces-to-insights.html" rel="alternate" type="text/html" title="Traces to Insights: Evaluating LLM Apps" /><published>2026-09-01T00:00:00-04:00</published><updated>2026-09-01T00:00:00-04:00</updated><id>https://www.ombulabs.ai/blog/traces-to-insights</id><content type="html" xml:base="https://www.ombulabs.ai/blog/traces-to-insights.html"><![CDATA[<p>Tracing helps answer an important question: what happened? But knowing what happened isn’t the same as knowing whether it was any good. That’s where evaluation comes in. And if you already have traces running in production, you’re closer to a solid evaluation system than you might think.</p>

<p>In this post, we’ll walk through the core evaluation loop, how to build an evaluation dataset from production traces, and how to use evaluation signals to debug and improve your application.</p>

<!--more-->

<h2 id="introduction">Introduction</h2>

<p><a href="/blog/why-llm-applications-need-tracing">In the previous post</a>, we explored the foundations of LLM observability and tracing. We discussed how traces give you visibility into your LLM application by capturing the full sequence of events across every request, from the initial prompt all the way through retrieval steps, tool calls, and model responses.</p>

<p>Evaluation is the process of systematically measuring how well your AI system performs across the full pipeline, from the intermediate steps like retrieval and tool use through to the final response. Rather than relying on intuition, anecdotal feedback, or reactive debugging, evaluation provides a structured way to determine whether your application is performing as intended. It transforms quality from a subjective judgment into something that can be measured, tracked, and improved over time.</p>

<p>Evaluating LLM applications comes down to a set of questions that traces help you answer: whether the model’s response was accurate, whether it actually solved the user’s problem, whether the retrieved context was relevant to the query, and whether the latest prompt change moved output quality in the right direction or quietly made things worse.</p>

<h2 id="the-cost-of-skipping-evaluation">The cost of skipping evaluation</h2>

<p>Before diving into how evaluation works, it’s worth being honest about what happens without it.</p>

<p>Without a systematic way to measure quality, improving an LLM application becomes largely guesswork. You fix one issue and introduce another. Your prompts grow longer and more complicated as you attempt to patch edge cases one by one. Performance tuning is speculative at best, there is no way to be sure if a change made things better or just different. And at scale, cost control becomes nearly impossible when you have no visibility into where your tokens are actually going.</p>

<p>A good evaluation system changes this. It makes debugging targeted, optimization data-driven, and improvement continuous.</p>

<h2 id="what-evaluation-actually-means-for-llm-apps">What evaluation actually means for LLM apps</h2>

<p>Unlike most software testing, where the expected output is deterministic, evaluating LLM applications means contending with open-ended, context-sensitive outputs that cannot be reduced to a simple pass/fail.</p>

<p>At a high level, LLM evaluation usually focuses on three dimensions: quality, behavior, and cost efficiency.</p>

<h3 id="quality">Quality</h3>

<p>Quality measures whether the application completes the task successfully. Is the answer accurate? Is it grounded in the right context? Does it handle uncertainty appropriately? Does it fail gracefully when it cannot answer? Quality evaluation helps define what an acceptable output looks like and what kinds of errors matter most for your application.</p>

<h3 id="behavior">Behavior</h3>

<p>Behavior measures whether the system acts the way you intended across different scenarios. This includes consistency, instruction following, tone, refusal behavior, tool usage, and edge case handling. A model may produce a high-quality answer in one case but behave unpredictably when the input changes slightly. Behavior-focused evaluation helps identify those inconsistencies before they become production issues.</p>

<h3 id="cost-efficiency">Cost efficiency</h3>

<p>Cost efficiency measures how much work the system does to produce a useful result. In LLM applications, cost is not limited to the final model call. Tokens may be spent during retrieval, tool selection, planning, summarization, intermediate reasoning steps, and retries. When evaluation is connected to tracing, you can inspect token usage trace by trace and span by span, making it easier to see where cost is accumulating and whether that cost is justified by better outcomes.</p>

<p>The organizing philosophy that ties these together is sometimes referred to as <strong>Eval Driven Development (EDD)</strong>. Before making changes to prompts, models, retrieval pipelines, or agent workflows, you define what success looks like and create datasets that represent the tasks your system needs to perform.</p>

<p>The most useful evaluation datasets are usually task-specific. Off-the-shelf benchmarks can be helpful for understanding general model capabilities, but they rarely capture the details of your application’s domain, users, constraints, or edge cases. A support chatbot, a code review assistant, and a document analysis workflow all need different evaluations because they fail in different ways.</p>

<p>Once those evaluations exist, they become a feedback loop for development. In this sense, evaluation is not a one-time quality gate. It is the foundation for continuously improving an LLM application. Tracing helps you see what happened inside the system. Evaluation helps you decide whether that behavior was correct, useful, and worth the cost.</p>

<h2 id="the-core-evaluation-loop-humans-define-models-scale">The core evaluation loop: humans define, models scale</h2>

<p>Before you can evaluate at scale, you need a foundation of human judgment. This is where traces become essential in providing domain experts the raw material to work with.</p>

<p>The process works in four stages:</p>

<p><strong>Define.</strong> Domain experts manually review a diverse sample of traces and establish what “good” looks like. This means writing explicit success criteria and rubrics that express what the model should and shouldn’t do in specific situations.</p>

<p><strong>Label.</strong> Those same experts evaluate a dataset of real traces, marking each one as Pass or Fail accompanied by a short critique explaining their reasoning. A binary system is intentionally simpler than a 1 to 5 scale. <a href="https://arxiv.org/html/2508.12792v2">LLM judges diverge from human scores</a>, and the disagreement typically worsens as the scoring scale grows more granular, which is why binary classification gives you a cleaner starting signal.</p>

<p><strong>Scale.</strong> Once you have a labeled dataset with human critiques, you can use a powerful LLM as a judge to replicate that judgment at scale. The human critiques feed directly into the evaluator prompt as few-shot examples, grounding the model’s assessments in real human reasoning. Use the most capable model you can afford here because this task requires advanced reasoning ability. Before you trust the output, measure precision and recall between the model’s grades and the human labels, and iterate on the evaluator prompt until agreement is comfortably high (in our experience, often in the 80–90% range).</p>

<p><strong>Calibrate.</strong> Human evaluation doesn’t stop once the model is running. Periodically, reviewers sample a portion of the model’s grades to check for drift. If the model-based evaluator starts diverging from human judgment, which happens for many reasons, you catch it early and retune.</p>

<p>This loop where humans define the criteria and models run the evals at scale is the engine that makes continuous improvement possible without drowning your team in manual review.</p>

<h2 id="building-your-evaluation-dataset-from-traces">Building your evaluation dataset from traces</h2>

<p>Your evaluation system is only as good as the data it runs on. This is one of the clearest arguments for investing in tracing early: production traces become the raw material for your evaluation dataset.</p>

<p>A common mistake when building evaluation systems is waiting until you have a perfect dataset before getting started. In practice, most teams begin with limited data and gradually improve their evaluations as their application matures.</p>

<p><strong>Start synthetic if you have to.</strong> If you’re early in development and don’t yet have a body of production traces, bootstrapping with synthetic test cases or open-source data is a reasonable starting point. But treat this as temporary scaffolding, not a foundation.</p>

<p><strong>Migrate to production data as soon as possible.</strong> Once your application is serving real users and collecting traces, those traces become one of the most valuable assets for improving quality. Every trace represents a real interaction, complete with user intent, contextual information, model decisions, tool calls, and outcomes. Rather than guessing how users might interact with your system, production traces show you exactly how they do.</p>

<p><strong>Label strategically.</strong>  Many teams immediately gravitate toward numerical scoring systems such as rating outputs on a scale from one to ten. While these scores appear more precise, they often introduce significant disagreement between reviewers. Even expert evaluators frequently differ by several points when assigning scores to the same response, making it difficult to establish reliable thresholds and compare results over time.</p>

<p>For this reason, it is often better to begin with binary classification tasks. Instead of asking whether a response deserves a score of seven or eight, ask simpler questions: was the response relevant to the user’s request? Did the answer remain consistent with the provided context? Was the retrieved information used correctly? Did the system follow the expected behavior?</p>

<p>Consistency and relevance are particularly effective starting points because they capture two of the most common failure modes in LLM applications while remaining straightforward for both humans and automated evaluators to assess.</p>

<p>When you’re starting out, review as much trace data as you can. Over time, as your dataset grows and your model stabilizes, you can sample more selectively. Three sampling strategies are worth knowing. Failure-driven sampling prioritizes traces the model flagged as low-confidence or that received poor scores, which is where your gaps are. Precision-focused sampling selects instances the model predicted as positive with high confidence, to catch false positives slipping through. Calibration sampling targets borderline cases where the model was uncertain, since those examples sharpen the evaluator’s judgment on the edges.</p>

<h2 id="evaluation-signals-from-automated-checks-to-human-review">Evaluation signals: from automated checks to human review</h2>

<p>Not all evaluation signals are equal, and not all of them require the same level of effort. A practical evaluation system layers them.</p>

<h3 id="automated-and-heuristic-checks">Automated and heuristic checks</h3>

<p>Automated and heuristic checks are your first line of defense. These evaluations are inexpensive, highly scalable, and deterministic. They work particularly well when the success criteria can be expressed as objective rules.</p>

<p>Examples include:</p>
<ul>
  <li>Output format validation</li>
  <li>Structured JSON schema checks</li>
  <li>Length requirements</li>
  <li>Toxicity detection</li>
  <li>PII detection</li>
  <li>Keyword matching</li>
  <li>Regular expression validation</li>
  <li>Safety and compliance rules</li>
</ul>

<p>For example, if an application is expected to return valid JSON, a simple parser can immediately determine whether the output passes or fails. If a support chatbot must include a ticket reference number, a regular expression can verify its presence.</p>

<p>These checks are often insufficient for measuring overall response quality, but they are valuable guardrails that catch obvious failures before more sophisticated evaluation methods are needed.</p>

<h3 id="llm-as-judge">LLM-as-judge</h3>

<p>As applications become more complex, many evaluation questions require subjective judgment: is the answer relevant? Did the model correctly use the retrieved context? Was the explanation helpful? These questions are difficult to encode as deterministic rules, which is why <a href="https://arxiv.org/abs/2306.05685">LLM-as-judge</a> systems have become increasingly popular.</p>

<p>In this approach, another language model evaluates the output and determines whether it meets specific criteria. Because LLM judges can reason about language and context, they can often approximate the decisions a human reviewer would make while operating at a much larger scale.</p>

<p>This makes them particularly useful for relevance evaluation, faithfulness and grounding checks, instruction-following verification, consistency evaluation, and comparative testing between model versions.</p>

<p>However, LLM judges are not perfect.</p>

<p>They can exhibit biases, overfit to particular response styles, or disagree with human reviewers. Different judge models may also produce different outcomes for the same example.</p>

<p>For this reason, LLM judges should be calibrated against human-labeled datasets whenever possible. Human reviewers establish the ground truth, and the judge is optimized to approximate those decisions at scale. As more human annotations become available, the judge can be continuously validated and improved.</p>

<p>The goal is not to replace humans entirely, but to extend human judgment to a volume that would otherwise be impossible to review manually.</p>

<h3 id="human-review">Human review</h3>

<p>Human review remains irreplaceable for certain tasks. Experienced domain experts can often identify subtle issues that automated systems and LLM judges miss entirely. They understand business context, user expectations, regulatory requirements, and nuanced quality concerns that are difficult to formalize.</p>

<p>Human review is particularly valuable when:</p>

<ul>
  <li>Defining evaluation criteria for a new application</li>
  <li>Investigating novel failure modes</li>
  <li>Reviewing high-impact decisions</li>
  <li>Evaluating subjective qualities such as usefulness or clarity</li>
  <li>Measuring alignment with business requirements</li>
  <li>Creating high-quality training and evaluation datasets</li>
</ul>

<p>In many organizations, subject matter experts become a critical part of the evaluation workflow. Their annotations help establish ground truth, identify emerging problems, and provide the feedback needed to continuously improve prompts, retrieval systems, and agent behaviors.</p>

<p>This is often where traces become especially valuable. Rather than reviewing isolated outputs, experts can inspect the entire execution path, including retrieved documents, tool invocations, intermediate steps, and model decisions.</p>

<p>Sometimes the most valuable evaluation question is simply: <strong>“Would I be comfortable shipping this response to a customer?”</strong></p>

<p>No automated metric can fully replace that judgment.</p>

<p>Beyond quality scores, your traces expose a rich set of signals worth monitoring. A well-instrumented trace surfaces application latency at the span level, so slow LLM invocations are identified precisely rather than buried in an end-to-end average. Token consumption is tracked per call, making it straightforward to spot inefficient prompts before they become a cost problem. In RAG pipelines, retrieved documents are recorded along with their relevance scores and rankings, giving you visibility into retrieval quality independently from generation quality. The prompt templates used at each step are captured for prompt-level debugging, alongside the LLM parameters (temperature, top-p, and other settings). Tool calls are recorded with their full function signatures, input parameters, and outcomes, so agentic behavior is inspectable rather than opaque. Finally, runtime exceptions such as rate limit errors, timeouts, and other failures are captured at the span level.</p>

<h2 id="using-traces-to-evaluate-debug-and-improve">Using traces to evaluate, debug, and improve</h2>

<p>This is where the value of tracing becomes most tangible. Once your evaluation system is running and scores are flowing back into your trace data, you can use that combined view to systematically improve your application across three dimensions.</p>

<p>Offline evaluation is your safety net before deployment. Before shipping a new prompt, a model change, or a new retrieval strategy, you run your evaluation dataset against the new version and compare scores to the baseline. Only ship if things improved or at least didn’t regress.</p>

<p>Online evaluation runs continuously against live production traces. Automated evaluators score incoming requests in real time, giving you a live quality signal that catches degradation as it happens rather than after users have already noticed. When scores drop, you have the traces to investigate immediately.</p>

<p>When something goes wrong, traces give you a precise map of what happened. Instead of guessing which part of your pipeline failed, you can follow the span tree to the exact step where things broke down:</p>

<p>When a workflow produces bad output, the trace shows exactly where things went sideways. Prompt failures are visible in the raw model response. Tool errors are captured as span events. Retrieval issues show up in the exact documents retrieved and their relevance scores and orchestration bugs such as loops, unexpected branching, and bottlenecks in multi-step agents become visible as anomalous span patterns.</p>

<p>Root cause analysis that once required reproducing issues locally now starts with filtering traces by low evaluation scores and reading the span tree.</p>

<p>With evaluation scores linked to individual traces, optimization becomes targeted rather than speculative. Span-level data makes several improvement workflows more concrete and systematic. When traces are scored and a prompt is underperforming, you can filter down to the worst performers, read through the critiques, identify the recurring pattern, update the prompt, and re-run evals.</p>

<p>Latency tuning benefits from the same granularity. Rather than guessing from an end-to-end average, you can see exactly which component is slow and address it directly, whether the culprit turns out to be a retrieval step, a particular model, or an inefficient tool call. Token usage data works similarly for cost. Seeing consumption broken down at the span level reveals which parts of your application are more expensive. Linking that spend to specific workflows or user segments takes it a step further, replacing a high-level aggregate bill with a per-feature breakdown that engineering and product teams can actually reason about together.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Tracing gives you visibility into what happened while evaluation gives you the understanding to act on it. Together, they form the feedback loop that turns an LLM application from something you ship and hope for the best into something you can measure, debug, and systematically improve.</p>

<p>The path forward is straightforward in principle, even if the details take work: instrument your application, collect traces, build a labeled evaluation dataset from real production data, establish a human-in-the-loop grading process, scale it with model-based evaluation, and run it continuously. Each iteration makes your evals sharper, your prompts better, and your application more reliable.</p>

<p>In our experience, teams whose LLM applications hold up over time tend not to be distinguished by <a href="https://www.ombulabs.ai/blog/benchmarks-dont-determine-good-agents">model quality</a>. What sets them apart is having feedback loops that tell them when things are working and when they’re not.</p>

<p>Working on an LLM application and not sure where to start with evaluation? <a href="https://www.ombulabs.ai/#contactus">Let’s talk!</a></p>]]></content><author><name>buys-fran</name></author><category term="generative-ai" /><summary type="html"><![CDATA[How evaluation turns production traces into a feedback loop: building a labeled dataset, scaling human judgment with LLM-as-judge, and using trace-linked scores to debug, optimize, and improve continuously.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/traces-to-insights.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/traces-to-insights.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A Blog MCP Server with FastMCP and pgvector</title><link href="https://www.ombulabs.ai/blog/blog-mcp-server-fastmcp-pgvector.html" rel="alternate" type="text/html" title="A Blog MCP Server with FastMCP and pgvector" /><published>2026-08-19T05:00:00-04:00</published><updated>2026-08-19T05:00:00-04:00</updated><id>https://www.ombulabs.ai/blog/blog-mcp-server-fastmcp-pgvector</id><content type="html" xml:base="https://www.ombulabs.ai/blog/blog-mcp-server-fastmcp-pgvector.html"><![CDATA[<p>In my previous post, <a href="https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process.html">AI Assistant for Our Blog Writing Process</a>, I introduced the assistant we built to help with our blog writing. It has two pieces: an <a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro">MCP (Model Context Protocol) server</a> that holds the source of truth for our two blogs, and a <a href="https://code.claude.com/docs/en/plugins">Claude Code plugin</a> that turns that information into workflows for suggesting topics, planning a post, drafting it and reviewing it.</p>

<p>That post was about deciding what to build. This one is about building it. We’ll go through the ingestion pipeline that gets our posts into a database, the embeddings that make the corpus searchable by meaning, the MCP server that exposes all of it, and the plugin that packages the workflows on top.</p>

<!--more-->

<h2 id="the-stack">The stack</h2>

<p>The pipeline and the server are one Python application, deployed to a single Heroku dyno:</p>

<ul>
  <li><a href="https://fastapi.tiangolo.com/">FastAPI</a> for the web application</li>
  <li><a href="https://gofastmcp.com/">FastMCP</a> for the MCP server</li>
  <li>SQLAlchemy and Postgres for storage</li>
  <li><a href="https://github.com/pgvector/pgvector"><code class="language-plaintext highlighter-rouge">pgvector</code></a> for the embedding column and similarity queries</li>
  <li><a href="https://www.voyageai.com/">Voyage</a> for the embeddings themselves</li>
</ul>

<p>Semantic search is a part of the work here, I won’t cover the details of embeddings, cosine distance, and why use cosine instead of inner product or Euclidean distance in this post. The <a href="https://www.ombulabs.ai/blog/semantic-search">Semantic Search with Sequel and pgvector</a> covers all of that. The stack there is Ruby, but the concepts are the same.</p>

<h2 id="getting-the-data-in">Getting the data in</h2>

<p>The blogs are the source of truth, not the database. Both of them are <a href="https://jekyllrb.com/">Jekyll </a> sites, which means every post is a markdown file with YAML front matter sitting in a GitHub repository. The pipeline’s job is to mirror that into something queryable.</p>

<p><img src="/blog/assets/images/blog-mcp-erd.png" alt="Entity relationship diagram of the blog post database" /></p>

<p>Posts and their content are separate tables. The content table is the one that carries the embedding:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PostContent</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">post_content</span><span class="sh">"</span>

    <span class="nb">id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">UUID</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid4</span><span class="p">)</span>
    <span class="n">post_id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">UUID</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span>
        <span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">"</span><span class="s">posts.id</span><span class="sh">"</span><span class="p">,</span> <span class="n">ondelete</span><span class="o">=</span><span class="sh">"</span><span class="s">CASCADE</span><span class="sh">"</span><span class="p">),</span>
        <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span>
        <span class="n">unique</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">content</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">embedding</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">Sequence</span><span class="p">[</span><span class="nb">float</span><span class="p">]]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">VECTOR</span><span class="p">(</span><span class="mi">1024</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">VECTOR(1024)</code> comes from <a href="https://github.com/pgvector/pgvector-python"><code class="language-plaintext highlighter-rouge">pgvector.sqlalchemy</code></a>, and 1024 is the dimension of the Voyage model we use. Splitting content off from metadata keeps the posts table small enough to query comfortably, since most of the tools only need metadata.</p>

<p>The posts table itself is mostly ordinary columns, with two constraints worth pointing at:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Post</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">posts</span><span class="sh">"</span>

    <span class="nb">id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">UUID</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid4</span><span class="p">)</span>
    <span class="n">source_id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">UUID</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">"</span><span class="s">sources.id</span><span class="sh">"</span><span class="p">,</span> <span class="n">ondelete</span><span class="o">=</span><span class="sh">"</span><span class="s">CASCADE</span><span class="sh">"</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">file_path</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="mi">255</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">title</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">published_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">DateTime</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">keywords</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">MutableList</span><span class="p">.</span><span class="nf">as_mutable</span><span class="p">(</span><span class="nc">ARRAY</span><span class="p">(</span><span class="n">String</span><span class="p">)),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">author_id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">int</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">"</span><span class="s">authors.id</span><span class="sh">"</span><span class="p">,</span> <span class="n">ondelete</span><span class="o">=</span><span class="sh">"</span><span class="s">CASCADE</span><span class="sh">"</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">author_ids</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">list</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">MutableList</span><span class="p">.</span><span class="nf">as_mutable</span><span class="p">(</span><span class="nc">ARRAY</span><span class="p">(</span><span class="n">Integer</span><span class="p">)),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">__table_args__</span> <span class="o">=</span> <span class="p">(</span>
        <span class="nc">UniqueConstraint</span><span class="p">(</span><span class="sh">"</span><span class="s">source_id</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">file_path</span><span class="sh">"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">uq_posts_source_file_path</span><span class="sh">"</span><span class="p">),</span>
        <span class="nc">CheckConstraint</span><span class="p">(</span><span class="sh">"</span><span class="s">author_id IS NOT NULL OR author_ids IS NOT NULL</span><span class="sh">"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">ck_author_id_or_author_ids</span><span class="sh">"</span><span class="p">),</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>That listing is trimmed, the real model has a few more columns. The unique constraint is on source plus file path rather than file path alone, because two blogs can quite happily have a post with the same filename. The check constraint encodes an editorial rule: a post has either one author or a list of them, never neither. <code class="language-plaintext highlighter-rouge">title</code> is <code class="language-plaintext highlighter-rouge">String(100)</code> for the same reason, because that fits the length limit our blogs enforce comfortably.</p>

<p>Front matter parsing uses the <a href="https://github.com/eyeseast/python-frontmatter"><code class="language-plaintext highlighter-rouge">python-frontmatter</code></a> library, and the pipeline normalizes the metadata schema where the two blogs disagree, so every consumer downstream sees one interface and doesn’t need to worry about which blog it’s looking at:</p>

<p><img src="/blog/assets/images/post-parsing-pipeline.png" alt="Flowchart of the post parsing pipeline." /></p>

<p>Re-reading four hundred posts on every sync would be wasteful, so the pipeline works from commit SHAs instead. It stores the SHA it last processed, asks GitHub what changed between then and now, and acts on the file statuses:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">added</span> <span class="o">=</span> <span class="p">{</span><span class="nb">file</span><span class="p">.</span><span class="n">filename</span> <span class="k">for</span> <span class="nb">file</span> <span class="ow">in</span> <span class="n">post_files</span> <span class="k">if</span> <span class="nb">file</span><span class="p">.</span><span class="n">status</span> <span class="o">==</span> <span class="n">GitHubFileStatus</span><span class="p">.</span><span class="n">ADDED</span><span class="p">}</span>
<span class="n">removed</span> <span class="o">=</span> <span class="p">{</span><span class="nb">file</span><span class="p">.</span><span class="n">filename</span> <span class="k">for</span> <span class="nb">file</span> <span class="ow">in</span> <span class="n">post_files</span> <span class="k">if</span> <span class="nb">file</span><span class="p">.</span><span class="n">status</span> <span class="o">==</span> <span class="n">GitHubFileStatus</span><span class="p">.</span><span class="n">REMOVED</span><span class="p">}</span>
<span class="n">updated</span> <span class="o">=</span> <span class="p">{</span><span class="nb">file</span><span class="p">.</span><span class="n">filename</span> <span class="k">for</span> <span class="nb">file</span> <span class="ow">in</span> <span class="n">post_files</span> <span class="k">if</span> <span class="nb">file</span><span class="p">.</span><span class="n">status</span> <span class="o">==</span> <span class="n">GitHubFileStatus</span><span class="p">.</span><span class="n">MODIFIED</span><span class="p">}</span>

<span class="k">if</span> <span class="n">added</span><span class="p">:</span>
    <span class="n">added_files</span> <span class="o">=</span> <span class="p">[</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_github_service</span><span class="p">.</span><span class="nf">get_contents</span><span class="p">(</span><span class="n">file_name</span><span class="o">=</span><span class="n">filename</span><span class="p">,</span> <span class="n">ref</span><span class="o">=</span><span class="n">current_sha</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">added</span>
    <span class="p">]</span>
    <span class="n">self</span><span class="p">.</span><span class="nf">_add_posts</span><span class="p">(</span><span class="n">added_files</span><span class="o">=</span><span class="n">added_files</span><span class="p">)</span>

<span class="k">if</span> <span class="n">removed</span><span class="p">:</span>
    <span class="n">self</span><span class="p">.</span><span class="n">_repos</span><span class="p">.</span><span class="n">post_repo</span><span class="p">.</span><span class="nf">bulk_delete</span><span class="p">(</span><span class="n">removed</span><span class="p">)</span>

<span class="k">if</span> <span class="n">updated</span><span class="p">:</span>
    <span class="n">updates</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_updated_posts</span><span class="p">(</span>
        <span class="n">current_sha</span><span class="o">=</span><span class="n">current_sha</span><span class="p">,</span>
        <span class="n">last_processed_sha</span><span class="o">=</span><span class="n">last_processed_sha</span><span class="p">,</span>
        <span class="n">updated</span><span class="o">=</span><span class="n">updated</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">self</span><span class="p">.</span><span class="n">_repos</span><span class="p">.</span><span class="n">post_repo</span><span class="p">.</span><span class="nf">bulk_update</span><span class="p">(</span><span class="n">updates</span><span class="p">)</span>
</code></pre></div></div>

<p>When there is no last processed SHA, which is to say on the very first run, it walks the whole repository instead. Updates are handled by fetching both versions of the file, diffing the parsed front matter key by key, and only writing what actually changed:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">changes</span> <span class="o">=</span> <span class="p">{</span>
    <span class="n">key</span><span class="p">:</span> <span class="n">value</span>
    <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">current_post_metadata</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">previous_content_metadata</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="o">!=</span> <span class="n">value</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">changes</span><span class="p">:</span>
    <span class="n">post_update</span><span class="p">.</span><span class="n">metadata</span> <span class="o">=</span> <span class="n">changes</span>

<span class="k">if</span> <span class="n">current_post</span><span class="p">.</span><span class="n">content</span> <span class="o">!=</span> <span class="n">previous_content</span><span class="p">.</span><span class="n">content</span><span class="p">:</span>
    <span class="n">post_update</span><span class="p">.</span><span class="n">content</span> <span class="o">=</span> <span class="n">current_post</span><span class="p">.</span><span class="n">content</span>
</code></pre></div></div>

<p>That last check is what keeps the embedding bill down. If a post’s body is untouched, there is no reason to re-embed it, so a typo fix in the front matter costs nothing.</p>

<h2 id="voyage-embeddings">Voyage embeddings</h2>

<p>Anthropic doesn’t offer its own embedding models, it recommends evaluating embedding vendors to decide on the best one for your use case. <a href="https://www.voyageai.com/">Voyage AI</a> features prominently and its models are documented as working particularly well with Claude’s retrieval systems, so that is the provider we used.</p>

<p>Embedding a post is a single call, and the text we embed is the title glued to the body:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">embeddings</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_voyage_service</span><span class="p">.</span><span class="nf">embed_many</span><span class="p">(</span>
    <span class="n">text</span><span class="o">=</span><span class="p">[</span>
        <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">post</span><span class="p">[</span><span class="sh">'</span><span class="s">metadata</span><span class="sh">'</span><span class="p">][</span><span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="s"> - </span><span class="si">{</span><span class="n">post</span><span class="p">[</span><span class="sh">'</span><span class="s">content</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span>
        <span class="k">for</span> <span class="n">post</span> <span class="ow">in</span> <span class="n">new_posts</span>
    <span class="p">],</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The title goes in because it is often the most concentrated description of what a post is about, and dropping it loses information that the body sometimes never states outright.</p>

<p>The service wrapping Voyage is small, and the important part to pay attention to here is the type on <code class="language-plaintext highlighter-rouge">input_type</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">EmbeddingInputType</span> <span class="o">=</span> <span class="n">Literal</span><span class="p">[</span><span class="sh">"</span><span class="s">document</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">query</span><span class="sh">"</span><span class="p">]</span>

<span class="n">BATCH_SIZE</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">50</span>


<span class="k">class</span> <span class="nc">VoyageService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">embed_one</span><span class="p">(</span>
        <span class="n">self</span><span class="p">,</span>
        <span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
        <span class="n">input_type</span><span class="p">:</span> <span class="n">EmbeddingInputType</span> <span class="o">=</span> <span class="sh">"</span><span class="s">document</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Sequence</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
        <span class="n">embeddings</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">client</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span>
            <span class="n">texts</span><span class="o">=</span><span class="p">[</span><span class="n">text</span><span class="p">],</span>
            <span class="n">model</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">EMBEDDING_MODEL</span><span class="p">,</span>
            <span class="n">input_type</span><span class="o">=</span><span class="n">input_type</span><span class="p">,</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">embeddings</span><span class="p">.</span><span class="n">embeddings</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>

    <span class="k">def</span> <span class="nf">embed_many</span><span class="p">(</span>
        <span class="n">self</span><span class="p">,</span>
        <span class="n">text</span><span class="p">:</span> <span class="n">Sequence</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
        <span class="n">input_type</span><span class="p">:</span> <span class="n">EmbeddingInputType</span> <span class="o">=</span> <span class="sh">"</span><span class="s">document</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Sequence</span><span class="p">[</span><span class="n">Sequence</span><span class="p">[</span><span class="nb">float</span><span class="p">]]:</span>
        <span class="n">embeddings</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">batch</span> <span class="ow">in</span> <span class="nf">batched</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">BATCH_SIZE</span><span class="p">):</span>
            <span class="n">embeddings</span><span class="p">.</span><span class="nf">extend</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="nf">_embed_batch</span><span class="p">(</span><span class="n">batch</span><span class="o">=</span><span class="n">batch</span><span class="p">,</span> <span class="n">input_type</span><span class="o">=</span><span class="n">input_type</span><span class="p">))</span>
        <span class="k">return</span> <span class="n">embeddings</span>
</code></pre></div></div>

<p>Voyage’s models take an <code class="language-plaintext highlighter-rouge">input_type</code> parameter, and passing the right one is not optional if you want good results. The model embeds a short question differently from the way it embeds a long document, because those two things are asymmetric in practice: somebody typing “zero-downtime Rails upgrades” is describing what they want to find, not providing a sample of it.</p>

<p>That asymmetry is why we ended up with two search tools rather than one:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">search_posts</span><span class="p">(</span>
    <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="n">_DEFAULT_SEARCH_HITS</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">SearchHit</span><span class="p">]:</span>
    <span class="sh">"""</span><span class="s">Semantic search over published blog posts.

    query should be a short natural-language phrase describing a topic (e.g. </span><span class="sh">"</span><span class="s">zero-downtime Rails upgrades</span><span class="sh">"</span><span class="s">).
    Omit source to search both blogs. Results are ordered by similarity score (higher = closer) and include a
    snippet; use get_post with a hit</span><span class="sh">'</span><span class="s">s file_path for full content.
    </span><span class="sh">"""</span>
    <span class="n">embedding</span> <span class="o">=</span> <span class="n">_voyage</span><span class="p">.</span><span class="nf">embed_one</span><span class="p">(</span><span class="n">text</span><span class="o">=</span><span class="n">query</span><span class="p">,</span> <span class="n">input_type</span><span class="o">=</span><span class="sh">"</span><span class="s">query</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">with</span> <span class="nf">session_scope</span><span class="p">()</span> <span class="k">as</span> <span class="n">db</span><span class="p">:</span>
        <span class="k">return</span> <span class="nc">PostQueryRepo</span><span class="p">(</span><span class="n">db</span><span class="p">).</span><span class="nf">similar_posts</span><span class="p">(</span>
            <span class="n">embedding</span><span class="o">=</span><span class="n">embedding</span><span class="p">,</span>
            <span class="n">source</span><span class="o">=</span><span class="n">source</span><span class="p">,</span>
            <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">,</span>
        <span class="p">)</span>


<span class="k">def</span> <span class="nf">find_related_posts</span><span class="p">(</span>
    <span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">exclude_file_path</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="n">_DEFAULT_SEARCH_HITS</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">SearchHit</span><span class="p">]:</span>
    <span class="sh">"""</span><span class="s">Find published posts most similar to a full draft or document.

    Pass the entire draft (or a section) as text, unlike search_posts, which expects a short query. Use this to find
    cross-reference and internal-link candidates for a post being written or reviewed. Set exclude_file_path when the
    draft is a revision of an existing post so it doesn</span><span class="sh">'</span><span class="s">t match itself.
    </span><span class="sh">"""</span>
    <span class="n">embedding</span> <span class="o">=</span> <span class="n">_voyage</span><span class="p">.</span><span class="nf">embed_one</span><span class="p">(</span>
        <span class="n">text</span><span class="o">=</span><span class="n">text</span><span class="p">,</span>
        <span class="n">input_type</span><span class="o">=</span><span class="sh">"</span><span class="s">document</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">with</span> <span class="nf">session_scope</span><span class="p">()</span> <span class="k">as</span> <span class="n">db</span><span class="p">:</span>
        <span class="k">return</span> <span class="nc">PostQueryRepo</span><span class="p">(</span><span class="n">db</span><span class="p">).</span><span class="nf">similar_posts</span><span class="p">(</span>
            <span class="n">embedding</span><span class="o">=</span><span class="n">embedding</span><span class="p">,</span>
            <span class="n">source</span><span class="o">=</span><span class="n">source</span><span class="p">,</span>
            <span class="n">exclude_file_path</span><span class="o">=</span><span class="n">exclude_file_path</span><span class="p">,</span>
            <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">,</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>The two functions do almost the same thing and differ in three places: what they call their first parameter, the <code class="language-plaintext highlighter-rouge">input_type</code> they pass, and whether they can exclude a file path. <code class="language-plaintext highlighter-rouge">search_posts</code> takes a phrase and embeds it as a query. <code class="language-plaintext highlighter-rouge">find_related_posts</code> takes an entire draft and embeds it as a document, which is what you want when the question is “what have we already published that this piece should link to”.</p>

<p><img src="/blog/assets/images/blog-mcp-embedding-paths.png" alt="Diagram of the query and document embedding paths" /></p>

<p><code class="language-plaintext highlighter-rouge">exclude_file_path</code> earns its place the first time you point the tool at a revision of a post that is already published. Without it the top hit is the post itself, which is correct and completely useless.</p>

<p>Both paths land on the same query. <code class="language-plaintext highlighter-rouge">pgvector</code> gives us a cosine distance operator through SQLAlchemy, so the ranking is a single <code class="language-plaintext highlighter-rouge">order_by</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">similar_posts</span><span class="p">(</span>
    <span class="n">self</span><span class="p">,</span>
    <span class="n">embedding</span><span class="p">:</span> <span class="n">Sequence</span><span class="p">[</span><span class="nb">float</span><span class="p">],</span>
    <span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">exclude_file_path</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">10</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">SearchHit</span><span class="p">]:</span>
    <span class="n">distance</span> <span class="o">=</span> <span class="n">PostContent</span><span class="p">.</span><span class="n">embedding</span><span class="p">.</span><span class="nf">cosine_distance</span><span class="p">(</span><span class="n">embedding</span><span class="p">).</span><span class="nf">label</span><span class="p">(</span><span class="sh">"</span><span class="s">distance</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">query</span> <span class="o">=</span> <span class="p">(</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Post</span><span class="p">,</span> <span class="n">PostContent</span><span class="p">,</span> <span class="n">distance</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">PostContent</span><span class="p">,</span> <span class="n">PostContent</span><span class="p">.</span><span class="n">post_id</span> <span class="o">==</span> <span class="n">Post</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">Source</span><span class="p">,</span> <span class="n">Post</span><span class="p">.</span><span class="n">source_id</span> <span class="o">==</span> <span class="n">Source</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="k">if</span> <span class="n">source</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">Source</span><span class="p">.</span><span class="n">repository_name</span> <span class="o">==</span> <span class="n">source</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">exclude_file_path</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">Post</span><span class="p">.</span><span class="n">file_path</span> <span class="o">!=</span> <span class="n">exclude_file_path</span><span class="p">)</span>

    <span class="n">rows</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">distance</span><span class="p">).</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>
</code></pre></div></div>

<p>Distance is what the database returns, and similarity is what a reader wants, so the hits get built with the subtraction done for them:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">SearchHit</span><span class="p">(</span>
    <span class="o">**</span><span class="n">self</span><span class="p">.</span><span class="nf">_to_summary</span><span class="p">(</span><span class="n">post</span><span class="o">=</span><span class="n">row</span><span class="p">.</span><span class="n">Post</span><span class="p">,</span> <span class="n">names</span><span class="o">=</span><span class="n">names</span><span class="p">).</span><span class="nf">model_dump</span><span class="p">(),</span>
    <span class="n">snippet</span><span class="o">=</span><span class="n">row</span><span class="p">.</span><span class="n">Post</span><span class="p">.</span><span class="n">description</span> <span class="ow">or</span> <span class="n">row</span><span class="p">.</span><span class="n">PostContent</span><span class="p">.</span><span class="n">content</span><span class="p">[:</span><span class="mi">300</span><span class="p">],</span>
    <span class="n">score</span><span class="o">=</span><span class="nf">round</span><span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">row</span><span class="p">.</span><span class="n">distance</span><span class="p">,</span> <span class="mi">4</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">description or content[:300]</code> fallback exists because our older posts predate us treating the description field as required, so a good few of them have nothing in it.</p>

<p>There is a cheap test for whether any of this is wired up correctly, and it is worth running before trusting the tool: paste a paragraph from a post you have already published and search for it. The post it came from should come back first. If it doesn’t, something in the chain is wrong, and it is much easier to find out that way than by squinting at similarity scores and wondering whether 0.62 is a good number.</p>

<h2 id="building-the-mcp-server">Building the MCP server</h2>

<p>With the data in place, the server is almost boring. FastMCP takes a name, a set of instructions, and an auth provider:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mcp</span> <span class="o">=</span> <span class="nc">FastMCP</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">PROJECT_NAME</span><span class="p">,</span>
    <span class="n">instructions</span><span class="o">=</span><span class="n">_INSTRUCTIONS</span><span class="p">,</span>
    <span class="n">auth</span><span class="o">=</span><span class="nf">_build_auth</span><span class="p">(),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Those instructions are sent to the client, so they are the first thing a model learns about the server. They are worth writing carefully. Ours says what the two blogs are and what each one covers, because a model that knows <a href="https://www.fastruby.io/blog">FastRuby.io</a> is the Rails blog and <a href="https://www.ombulabs.ai/blog">OmbuLabs.ai</a> is the AI blog will pick the right <code class="language-plaintext highlighter-rouge">source</code> filter without being told every time.</p>

<p>Access is Google OAuth with the consent screen set to internal, which means our Workspace organization is the access list and there is no permission code of our own to maintain.</p>

<p>Tools are plain functions, registered one call at a time:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Posts tools
</span><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">posts</span><span class="p">.</span><span class="n">list_posts</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">posts</span><span class="p">.</span><span class="n">get_post</span><span class="p">)</span>

<span class="c1"># Search tools
</span><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">search</span><span class="p">.</span><span class="n">search_posts</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">search</span><span class="p">.</span><span class="n">find_related_posts</span><span class="p">)</span>

<span class="c1"># Stats tools
</span><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">stats</span><span class="p">.</span><span class="n">get_content_stats</span><span class="p">)</span>

<span class="c1"># Style tools
</span><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">style</span><span class="p">.</span><span class="n">check_style</span><span class="p">)</span>

<span class="c1"># Validation tools
</span><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">validation</span><span class="p">.</span><span class="n">validate_post_metadata</span><span class="p">)</span>
</code></pre></div></div>

<p>Resources are registered the same way, with a URI and a MIME type, and the ones with <code class="language-plaintext highlighter-rouge">{source}</code> in them are templates that take the blog as a parameter:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://sources</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">application/json</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">sources</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://{source}/style-guide</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">text/markdown</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">style_guide</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://{source}/brand</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">text/markdown</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">brand</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://{source}/frontmatter-template</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">text/markdown</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">frontmatter_template</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://{source}/taxonomy</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">application/json</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">taxonomy</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://{source}/authors</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">application/json</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">authors</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">resource</span><span class="p">(</span><span class="sh">"</span><span class="s">blog://writing-principles</span><span class="sh">"</span><span class="p">,</span> <span class="n">mime_type</span><span class="o">=</span><span class="sh">"</span><span class="s">text/markdown</span><span class="sh">"</span><span class="p">)(</span><span class="n">resources</span><span class="p">.</span><span class="n">writing_principles</span><span class="p">)</span>
</code></pre></div></div>

<p>Both listings are trimmed to the blog-post surface.</p>

<p>The division between the two is the one design decision in here that actually mattered. Tools are things a skill can do, and resources are documents a skill can read. A skill asking “what is our house style” should get a document, not a function call that returns a paragraph at a time.</p>

<p>Serving it is three statements:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mcp_app</span> <span class="o">=</span> <span class="n">mcp</span><span class="p">.</span><span class="nf">http_app</span><span class="p">(</span><span class="n">path</span><span class="o">=</span><span class="sh">"</span><span class="s">/mcp</span><span class="sh">"</span><span class="p">,</span> <span class="n">stateless_http</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="n">app</span> <span class="o">=</span> <span class="nc">FastAPI</span><span class="p">(</span>
    <span class="n">title</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">PROJECT_NAME</span><span class="p">,</span>
    <span class="n">lifespan</span><span class="o">=</span><span class="n">mcp_app</span><span class="p">.</span><span class="n">lifespan</span><span class="p">,</span>
<span class="p">)</span>

<span class="n">app</span><span class="p">.</span><span class="nf">mount</span><span class="p">(</span><span class="sh">""</span><span class="p">,</span> <span class="n">app</span><span class="o">=</span><span class="n">mcp_app</span><span class="p">)</span>
</code></pre></div></div>

<p>Passing <code class="language-plaintext highlighter-rouge">mcp_app.lifespan</code> to FastAPI is easy to miss and the server will not work without it, because that is what starts and stops the MCP session manager alongside the app.</p>

<p><img src="/blog/assets/images/blog-mcp-request-path.png" alt="Sequence diagram of a review running through the plugin and the MCP server" /></p>

<p><code class="language-plaintext highlighter-rouge">stateless_http=True</code> is there because of a problem we ran into rather than a preference. Streamable HTTP used to keep session state on the server and hand the client a session ID. Our dynos restart daily, and every deploy restarts them too. After each restart, every connected client was holding a session ID the server had never heard of, and got a perfectly spec-compliant 404 for its trouble. The only fix on the client side is to reconnect.</p>

<p>The protocol has since moved the same way. The <a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/">2026-07-28 revision</a>, published two days before this post, retires sessions and the <code class="language-plaintext highlighter-rouge">Mcp-Session-Id</code> header entirely, along with the <code class="language-plaintext highlighter-rouge">initialize</code> handshake. What was a workaround for us is now simply how the transport works, so if you are reading this some time later, treat stateless as the only path rather than as a choice.</p>

<h2 id="docstrings-and-type-hints">Docstrings and type hints</h2>

<p>FastMCP builds each tool’s advertised description from the function’s docstring, and its input schema from the type hints. That has a consequence worth sitting with: the docstring is not developer documentation. It is the interface, and its reader is a model deciding which tool to call.</p>

<p>Here is <code class="language-plaintext highlighter-rouge">get_post</code>, which is about as small as a tool gets:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_post</span><span class="p">(</span><span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span><span class="p">,</span> <span class="n">file_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">PostDetail</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Fetch a single blog post with its full markdown content.

    file_path is the identifier returned by list_posts. Errors if no post matches in the given source.
    </span><span class="sh">"""</span>
</code></pre></div></div>

<p>One sentence saying what it returns, one saying where the identifier comes from, one saying what happens when it fails. That middle sentence is doing the real work. <code class="language-plaintext highlighter-rouge">file_path</code> is a meaningless string on its own, and without being told, a model will try to guess one. Naming the tool that produces it turns two tools into a sequence.</p>

<p>The same trick runs the other way in <code class="language-plaintext highlighter-rouge">list_posts</code>, whose docstring ends by pointing at <code class="language-plaintext highlighter-rouge">get_post</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">list_posts</span><span class="p">(</span>
    <span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">category</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">content_type</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">author</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">keyword</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">published_after</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">published_before</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="n">_DEFAULT_LIMIT</span><span class="p">,</span>
    <span class="n">offset</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">PostSummary</span><span class="p">]:</span>
    <span class="sh">"""</span><span class="s">List blog post summaries, newest first.

    Returns metadata only, no post content or body. Includes the post</span><span class="sh">'</span><span class="s">s url (link) that lands directly in the post</span><span class="sh">'</span><span class="s">s
    page in the blog.

    Filter by source (omit to search both blogs), category or content_type (exact names), author (username), keyword
    (exact match), and/or a published_after/published_before date range. Page with limit/offset. Use get_post with a
    returned file_path to fetch full post content.
    </span><span class="sh">"""</span>
</code></pre></div></div>

<p>“Returns metadata only, no post content or body” is there to stop a model calling <code class="language-plaintext highlighter-rouge">list_posts</code> and then complaining that it cannot see the text. “Exact names” is there because category and content type are not free text, and a model that guesses <code class="language-plaintext highlighter-rouge">AI</code> instead of <code class="language-plaintext highlighter-rouge">artificial-intelligence</code> gets nothing back.</p>

<p>The type hints do the rest. That signature is what the client actually receives:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"anyOf"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"enum"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"ombulabs.com"</span><span class="p">,</span><span class="w"> </span><span class="s2">"fastruby.io"</span><span class="p">],</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">},</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"null"</span><span class="p">}],</span><span class="w">
      </span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"anyOf"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">},</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"null"</span><span class="p">}],</span><span class="w"> </span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">},</span><span class="w">
    </span><span class="nl">"limit"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"integer"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"offset"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"integer"</span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">BlogSources</code> enum becomes a JSON schema enum, which means the two valid blog identifiers are in front of the model rather than in a document it has to remember to read. That schema is trimmed, the real one has every filter in it.</p>

<p>Going back to the two search tools from earlier, this is why their docstrings look the way they do. <code class="language-plaintext highlighter-rouge">search_posts</code> says “query should be a short natural-language phrase describing a topic” and gives an example. <code class="language-plaintext highlighter-rouge">find_related_posts</code> says “pass the entire draft (or a section) as text, unlike search_posts, which expects a short query”. Each one names the other, because the failure mode is not a model that cannot use either tool, it is a model that reaches for the wrong one and gets mediocre results without ever knowing why.</p>

<p>When tools got picked incorrectly during testing, the fix was almost always in the description. Writing text whose audience is a model takes some getting used to, but the description is usually the cheapest thing to change.</p>

<p>Not every tool needs to be interesting, either. Two of ours contain no model, no embeddings, and no AI buzzwords of any kind:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">style</span><span class="p">.</span><span class="n">check_style</span><span class="p">)</span>
<span class="n">mcp</span><span class="p">.</span><span class="nf">tool</span><span class="p">(</span><span class="n">validation</span><span class="p">.</span><span class="n">validate_post_metadata</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">check_style</code> is a handful of compiled regular expressions run over a draft. <code class="language-plaintext highlighter-rouge">validate_post_metadata</code> is a series of <code class="language-plaintext highlighter-rouge">if</code> statements and a few database lookups. Neither would raise an eyebrow as a standalone script, and that is rather the point: an MCP tool does not have to be anything more than a function you already had. What the server adds is a runtime, and that turned out to matter more than expected. A script has to run somewhere, which means everyone using it needs the right language installed and the right version of the file. A tool on the server runs identically for everyone, the rules ship in the same deploy as the code that enforces them, and nobody is ever checking a draft against last month’s version.</p>

<p>The other thing it adds is a result rather than an opinion. Ask a model whether a draft uses banned constructions and you get a judgment, which is fine when judgment is what you need and unhelpful when it isn’t. A regex either matches or it doesn’t. <code class="language-plaintext highlighter-rouge">validate_post_metadata</code> goes further and checks categories, content types and author usernames against what is in the database right now, so a draft claiming a category we retired last year fails, and fails with the reason. No prompt can do that, however well written.</p>

<h2 id="testing-with-the-inspector">Testing with the Inspector</h2>

<p>The <a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a> makes a server easy to test. It connects the way a client would, lists the tools and resources you have registered, and lets you call them by hand and read exactly what comes back. FastMCP ships a wrapper around it, so there is nothing to wire up separately. It runs the Inspector through <code class="language-plaintext highlighter-rouge">npx</code>, so you need Node available:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastmcp dev inspector app/mcp/server.py:mcp
</code></pre></div></div>

<p>This gives you a client attached to your server, and three things are worth checking every time.</p>

<p><img src="/blog/assets/images/mcp-inspector-example.png" alt="Screenshot of the list_posts tool through the Inspector UI" /></p>

<p>The first is whether the tools appear with the descriptions you meant. This is the only place you see your docstrings the way a model sees them, and reading them there rather than in the source is a different experience. Ours got shorter and more specific after the first look.</p>

<p>The second is the input schema. Enums should be enums, defaults should be the defaults you intended, and optional parameters should be genuinely optional. A <code class="language-plaintext highlighter-rouge">BlogSources | None = None</code> hint that quietly became a required string is not something you notice from the Python side.</p>

<p>The third is the output. Make sure the tool returns what you expect it to, and in the right shape. This is what a tool actually returns:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PostSummary</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">source</span><span class="p">:</span> <span class="n">BlogSources</span>
    <span class="n">title</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">description</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">file_path</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">authors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span>
    <span class="n">category</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span>
    <span class="n">published_at</span><span class="p">:</span> <span class="n">UTCDatetime</span>
    <span class="n">content_type</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">link</span><span class="p">:</span> <span class="nb">str</span>


<span class="k">class</span> <span class="nc">SearchHit</span><span class="p">(</span><span class="n">PostSummary</span><span class="p">):</span>
    <span class="n">snippet</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">score</span><span class="p">:</span> <span class="nb">float</span>
    <span class="sh">"""</span><span class="s">Cosine similarity; higher = more similar.</span><span class="sh">"""</span>
</code></pre></div></div>

<p>There are no foreign keys in there. <code class="language-plaintext highlighter-rouge">category</code> is a name, not a <code class="language-plaintext highlighter-rouge">category_id</code>. <code class="language-plaintext highlighter-rouge">authors</code> is a list of display names, not a list of integers pointing at another table. <code class="language-plaintext highlighter-rouge">link</code> is a URL the model can hand straight to the reader, assembled on the way out. All of that costs a join and saves the model from either asking a second question or, worse, guessing.</p>

<p>The Inspector is a mirror though, not a gate. It will happily accept some things a real client will not, so the last step is always to install the thing where it is actually going to be used and try a real task. Ours was straightforward enough: point it at a published post, ask for related articles, and see whether the answers are ones a person would have given.</p>

<h2 id="the-plugin">The plugin</h2>

<p>The server holds what is true about the blogs. The plugin holds how we work, and it contains no code at all. A skill is a markdown file with front matter:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">blog-reviewer</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Review a blog post draft for FastRuby.io or OmbuLabs.ai before the author opens a PR. Verifies claims (factual accuracy, overstatement, stale time-bound statements), then style and brand compliance including AI-drift patterns, then front matter validity and cross-references. Use when the user asks to review a blog post, check a draft, fact-check a post, or invokes /blog-reviewer, optionally with a file path.</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>That description is long deliberately. It is what Claude Code matches against when deciding whether the skill is relevant, so it names the artifact, both blogs, what the skill actually does and the phrasings someone might reach for. Check out our <a href="https://www.ombulabs.ai/blog/how-to-write-a-claude-code-skill">how to write a Claude Code skill</a> post for more detail.</p>

<p>The body of the file is where the interesting constraint lives. Everything in a <code class="language-plaintext highlighter-rouge">SKILL.md</code> is loaded the moment the skill triggers, and it stays loaded. Context is finite and it is shared with the thing the author is actually working on. So <code class="language-plaintext highlighter-rouge">SKILL.md</code> does as little as possible. It says what the phases are, in what order, and where the detail for each one lives, for example:</p>

<table>
  <thead>
    <tr>
      <th>Pass</th>
      <th>What it checks</th>
      <th>Read</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1. Claims</td>
      <td>Factual accuracy, overstatement, stale time-bound statements, dropped nuance</td>
      <td><code class="language-plaintext highlighter-rouge">references/claims-review.md</code></td>
    </tr>
    <tr>
      <td>2. Style</td>
      <td>Banned AI-drift patterns, style guide adherence, brand rules</td>
      <td><code class="language-plaintext highlighter-rouge">references/style-review.md</code></td>
    </tr>
    <tr>
      <td>3. Mechanics</td>
      <td>Front matter validity, cross-references, link verification</td>
      <td><code class="language-plaintext highlighter-rouge">references/mechanics-review.md</code></td>
    </tr>
  </tbody>
</table>

<p>Each reference file gets read when its pass starts and not before. The protocol for checking claims is detailed, and none of that detail is useful while the style pass is running, so it is not in the room. The router is enough to know what to do next and where to look it up. That split pays off twice. Context stays available for the work rather than the instructions, and any phase can be rewritten by editing one file, without touching the skill that calls it.</p>

<p>One rule in every skill matters more than the rest, and it is the seam between the two halves of this post:</p>

<blockquote>
  <p>Rules are fetched, not remembered. Read <code class="language-plaintext highlighter-rouge">blog://{source}/style-guide</code>, <code class="language-plaintext highlighter-rouge">blog://{source}/brand</code>, and <code class="language-plaintext highlighter-rouge">blog://{source}/frontmatter-template</code> before the style and mechanics passes.</p>
</blockquote>

<p>No skill contains a style rule. They fetch the resource. Change the style guide on the server and every skill picks it up on its next run, with no plugin release and nothing for anyone to install.</p>

<p>The plugin also ships its own <code class="language-plaintext highlighter-rouge">.mcp.json</code> pointing at the server, which is what turns installation into one step instead of a setup guide.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"blog"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://mcp-server.com/mcp"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Distribution is a private GitHub repository acting as its own marketplace, so access to the repo is access to the plugin and updates ride on <code class="language-plaintext highlighter-rouge">git push</code>.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Building an AI-powered solution to solve an operational workflow problem can be quite easy. None of this is fancy or unusual, the stack is as common and lean as it gets, and the plugin is just text files. Only two decisions really shaped it: what belongs on the server versus in the skills, and what gets checked by code versus by judgment. Both of those come down to knowing which parts of your own process are stable enough to encode.</p>

<p>If you build something like this, the part that will need looking after is not the part you would expect. The skills are easy to change and the server is small. The pipeline is what quietly decides whether any of it is useful, because a corpus that stops being current takes the tools down with it without ever throwing an error. Everything downstream trusts that data, so the ingestion side is where the attention belongs once the interesting work is done.</p>

<p>Need help deciding the best way to leverage your data for productivity gains? <a href="/contact">Let’s talk!</a></p>]]></content><author><name>abizzinotto</name></author><category term="ai-agents" /><summary type="html"><![CDATA[How we built the MCP server behind our AI Assistant for our blog writing process: an ingestion pipeline, pgvector search, FastMCP tools and resources, and the Claude Code plugin on top.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/a-blog-mcp-server-with-fastmcp-and-pgvector.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/a-blog-mcp-server-with-fastmcp-and-pgvector.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Claude’s New Watermark: What It Means</title><link href="https://www.ombulabs.ai/blog/claude-watermark-myths.html" rel="alternate" type="text/html" title="Claude’s New Watermark: What It Means" /><published>2026-08-14T05:30:00-04:00</published><updated>2026-08-14T05:30:00-04:00</updated><id>https://www.ombulabs.ai/blog/claude-watermark-myths</id><content type="html" xml:base="https://www.ombulabs.ai/blog/claude-watermark-myths.html"><![CDATA[<p>If your team uses Claude to draft client-facing copy, documents, or images, you have probably seen some version of the news by now: Anthropic announced that Claude’s output will carry an imperceptible watermark in text and signed provenance metadata in files, worldwide, as its implementation of the <a href="https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist">EU AI Act’s Article 50 transparency rules</a>. A lot of comments and interpretations have followed: detectors can now catch your AI-assisted content, you can strip the watermark out if you find the hidden characters, your clients can trace a document back to you.</p>

<p>Most of that is wrong, and the loudest claim, that every Claude output is already watermarked, happens to be the easiest one to check. So we checked. On August 13, we generated images with Claude on two different surfaces, the desktop app and the API, and inspected every file for the provenance metadata Anthropic described. None of it is there yet.</p>

<p>So let’s take a look at what Anthropic actually committed to, what our own testing found, and why the four scariest claims circulating about the text watermark don’t hold up.</p>

<!--more-->

<h2 id="what-we-could-actually-verify">What we could actually verify</h2>

<p>The first half of Anthropic’s announcement is something anyone can verify. Provenance metadata based on the <a href="https://c2pa.org/">C2PA standard</a> (the Coalition for Content Provenance and Authenticity) is designed to be read: there’s a free command-line tool, <a href="https://github.com/contentauth/c2patool">c2patool</a>, and a <a href="https://verify.contentauthenticity.org/">web-based verifier</a>, and a marked file will show you its full manifest, digitally signed, including which software produced it. We first ran the tool against a known-marked sample image to confirm it reads manifests correctly, and it does.</p>

<p>Then we generated images with Claude and checked those. We used both of Anthropic’s current top models, Claude Opus 5 and Claude Fable 5, on two different surfaces: the desktop app’s image feature, downloading the files directly (screenshots strip metadata, so we avoided them), and the API, where Claude creates image files through its code execution tool. Every check came back the same way:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ c2patool claude-image.png
Error: No claim found
</code></pre></div></div>

<p>No manifest, no signature, nothing to read. As of August 13, 2026, none of the Claude-generated files we produced carried the provenance metadata the announcement describes.</p>

<p>The fine print explains why. Anthropic’s own article, <a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content">How Claude marks AI-generated content</a>, says that “Claude models launched on or after August 2, 2026 will support machine-readable marking at launch.” There haven’t been new models launched since that date, so that category is, for the moment, empty. For everything already released, the same page says the law includes a transition period and that Anthropic is “working to add marking support for those models as well,” with no timeline attached. That can change any week, and we’d expect it to. But it means the announcement is a commitment plus work in progress, not a switch that has been flipped, and any advice that starts from “your Claude output is already watermarked” is starting from a claim that isn’t true at this moment in time.</p>

<h2 id="what-anthropic-actually-committed-to">What Anthropic actually committed to</h2>

<p>The commitment covers two mechanisms that behave nothing alike, and most of the confused commentary comes from blending them together.</p>

<p>For text, Anthropic says it will weave an imperceptible watermark directly into what Claude writes. In their words: “You won’t see it, and it doesn’t change the meaning, quality, or readability.” There are no marks to spot and nothing added to the text, the signal lives in the writing itself, in a way we’ll unpack in the first myth below. Because it’s part of the words, it travels wherever the words are copied.</p>

<p>For files, the mechanism is entirely different: digitally signed metadata following the C2PA standard, attached to generated files in formats like <code class="language-plaintext highlighter-rouge">.png</code>, <code class="language-plaintext highlighter-rouge">.jpg</code>, and <code class="language-plaintext highlighter-rouge">.svg</code>. One clarification worth making, because a lot of coverage gets it wrong: Anthropic has no image-generation model. Claude doesn’t produce images the way DALL-E or Midjourney do. The images it makes are files it creates, drawings it renders in the app, or charts produced by code it writes. So the C2PA half is file provenance, a signed record that says “Claude produced this file, here’s whether it was altered since,” not a watermark baked into the pixels of an AI-generated picture. That distinction matters later, when we get to what survives editing.</p>

<p>The scope is genuinely broad. Anthropic says marking will apply to output from supported models “wherever Claude is offered, worldwide,” across its own products and through cloud partners, not just for EU users. The reason is the one we covered in our Article 50 checklist: the EU AI Act requires providers to mark generative output in a machine-readable format, and Anthropic signed the Article 50(2) <a href="https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content">Code of Practice on Transparency of AI-Generated Content</a> as both a model provider and a system provider. Shipping one global mechanism is simpler than maintaining an EU-only one, so worldwide output gets the EU treatment.</p>

<h2 id="myth-1-its-hidden-characters-you-can-strip-out">Myth 1: it’s hidden characters you can strip out</h2>

<p>Advice circulating on this one ranges from “paste the text through a plain-text editor” to scripts that promise to delete the watermark by removing invisible Unicode characters. All of it rests on the same misunderstanding of what a text watermark is.</p>

<p>Some earlier tools did mark text with invisible characters: zero-width spaces, special joiners, lookalike letters from other alphabets. Those marks are trivially findable with a character inspector and trivially strippable, which is exactly why nobody serious builds watermarks that way anymore. Anthropic hasn’t published its exact scheme yet, but everything in its description, imperceptible, surviving copy and paste, detectable only in aggregate, matches how modern statistical text watermarks work, the approach documented publicly in <a href="https://www.nature.com/articles/s41586-024-08025-4">Google DeepMind’s SynthID-Text paper</a>.</p>

<p>The plain-language version: as a model writes, it constantly chooses between words that would all be fine, “choose” versus “pick” versus “select.” A statistical watermark nudges those choices in a pattern that looks completely natural in any single sentence but becomes measurable across enough text, to a verifier that knows what pattern to test for. The watermark isn’t hidden <em>in</em> the text as an extra ingredient. It is the text.</p>

<p>Two things follow. Character-stripping tools do nothing, because there is nothing to strip. Anything selling you watermark removal on that basis is selling you a placebo. And copying the text anywhere, email, Word, your CMS, carries the watermark with it, because the words themselves are the signal.</p>

<h2 id="myth-2-anyone-can-now-detect-claude-text">Myth 2: anyone can now detect Claude text</h2>

<p>A watermark that lives in word-choice patterns can only be read by a verifier that knows which pattern to test for. That knowledge sits with whoever generated the watermark, in this case Anthropic. A statistical watermark is not a public label, it’s closer to a signature that only the signer can check for.</p>

<p>And right now, nobody can check for it, including Anthropic’s own customers. There is no detection tool. Anthropic says it is “working to enable users and other third parties to detect Claude’s embedded watermarks,” with details promised in forthcoming technical documentation, and as of this writing that documentation hasn’t shipped. So the fear of a client quietly scanning your deliverables for Claude’s watermark fails twice over: the marking isn’t being applied yet, and the instrument to read it doesn’t exist.</p>

<p>What about the AI detectors that already exist online? Those are a different thing entirely. Public detectors don’t read watermarks, they guess, from surface features of the writing, whether text looks machine-generated. Their track record is poor in both directions: they miss AI text that’s been lightly edited, and they flag human writing as AI, a problem <a href="https://arxiv.org/abs/2304.02819">documented most starkly for non-native English speakers</a>. A detector verdict on your copy, or a client’s copy, tells you very little today, and it will still be guesswork after Anthropic’s watermark goes live, because reading the actual watermark will require Anthropic’s verification mechanism, not a third-party guessing tool.</p>

<h2 id="myth-3-it-identifies-you">Myth 3: it identifies you</h2>

<p>The version of this myth we’ve seen most often goes: the watermark ties the content to your Anthropic account, so anyone who checks it learns who made it, or even what you asked for. Neither half matches what these mechanisms are for.</p>

<p>Start with what the law actually demands, because that’s what shaped the design. Article 50 requires generative output to be marked as artificially generated. The obligation is about <em>what kind of thing this is</em>, AI-generated content, not <em>whose</em> it is. Nothing in Anthropic’s announcement or documentation describes encoding user identity, and the text watermark as described is a model-level pattern: the same kind of signal in everyone’s Claude output, saying “a model produced this,” not a per-customer serial number. Anthropic hasn’t published the technical specification yet, so we’d stop short of absolute guarantees, but “it tells clients it was you” is not a feature in any published description.</p>

<p>The C2PA side is easier to check, because manifests are designed to be readable. A manifest records which software produced the file, a digital signature, a timestamp, and what operations were performed. An author name appears only if the creator’s tool explicitly wrote one in. The sample manifest we inspected named its creator because its test author chose to embed that. There’s no covert channel to worry about here: whatever a Claude-generated file ends up disclosing, you’ll be able to read it yourself with the same free tool we used, and so will your client.</p>

<p>It’s also worth remembering where documents actually leak identity today: Word’s author metadata, tracked changes, and file properties reveal far more about who touched a document than anything in this announcement.</p>

<h2 id="myth-4-editing-removes-it-or-nothing-does">Myth 4: editing removes it, or nothing does</h2>

<p>This one circulates in both directions: “any edit kills the watermark” and “it survives everything, don’t bother.” The honest answer is in between, and it’s different for text than for files.</p>

<p>For text, the only robustness information that exists is Anthropic’s own, so we’ll label it as such: their documentation says watermarks “may persist through some editing” but can be lost when text is “heavily edited, paraphrased, translated, or mixed” with other content. That is consistent with how a statistical watermark has to behave. The signal accumulates across many small word choices, so a light copyedit leaves most of it intact, while a heavy rewrite replaces the very choices that carried it. Where the threshold sits, nobody outside Anthropic can currently measure, because there’s no verifier to measure with. When the detection tooling ships, we intend to test it and publish what we find.</p>

<p>For files, the picture is close to the opposite, and it follows from the mechanism. C2PA metadata is attached to the file, not woven through the content. Send the file as an attachment or post it for download, and the manifest travels along, signed and intact. But take a screenshot of the image, re-export it through a tool that doesn’t speak C2PA, or run it through a platform that strips metadata on upload, and the manifest is simply gone. The standard is built to be tamper-evident, not removal-proof: editing a file with C2PA-aware tools leaves a recorded trail, while a missing manifest proves nothing at all about where a file came from.</p>

<p>So the text watermark lives in the content and dies by rewriting. The file record lives on the container and dies by re-rendering. Anyone selling you a workflow that promises either “watermark-free output” or “tamper-proof provenance” today is ahead of what the mechanisms support.</p>

<h2 id="what-this-means-for-your-business">What this means for your business</h2>

<p>The practical reading of all this is calmer than the headlines, but it does ask something of you.</p>

<p>Nothing about your existing Claude-assisted content changed this month. It isn’t watermarked, nothing can scan it, and no client is about to trace it. What did change is the direction of travel: marking is coming, worldwide, at the vendor level, and the window before it arrives is the right time to decide your disclosure posture rather than have it decided for you. If AI-assisted drafting is part of how you produce client work, the durable move is to say so on your own terms, in your engagement letters or your content process, before a marked file or a headline prompts the question. A client who hears your AI policy from you is a client; a client who thinks they caught you is a problem, and after marking goes live, “we never mentioned it” stops being a neutral choice.</p>

<p>Two narrower points. First, vendor marking doesn’t discharge your own obligations. Article 50 puts duties on deployers, visible labeling of deepfake-style media, disclosure for AI-generated text on matters of public interest, that exist independently of whatever Anthropic embeds machine-readably in the output. The checklist half of this is in <a href="https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist">our Article 50 post</a>. Second, what your AI vendors embed in their output now belongs on the list of <a href="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions">questions you ask them</a>, alongside data handling and guardrails: which outputs get marked, when, and what the verification story is.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The announcement is real, the scope is genuinely worldwide, and the direction is clear. But as of the day we tested, none of it has reached Claude’s actual output: the files we generated on two surfaces carried no provenance metadata, no model that marks at launch exists yet, and the tool that would read a text watermark hasn’t shipped. Against that baseline, the four claims we walked through all fall: the watermark isn’t strippable characters, public detectors can’t read it, nothing published suggests it names you, and editing sits between the two extremes people claim, differently for text than for files.</p>

<p>We could verify the file half ourselves, and we did. The text half can’t be verified by anyone outside Anthropic yet, which is worth remembering whenever someone states confidently what the text watermark does. When the detection documentation ships and marking goes live, we’ll re-run all of this and publish what actually survives.</p>

<p>Everything here reflects what was published and testable on August 13, 2026, and this area is moving quickly, so check against Anthropic’s current documentation before relying on the details.</p>

<p>Not sure what your AI-assisted content will carry, or what to tell clients before it does? <a href="/#contact-us">Let’s talk</a>.</p>]]></content><author><name>abizzinotto</name></author><category term="ethics-and-responsible-ai" /><summary type="html"><![CDATA[Anthropic says Claude's output will carry a watermark and provenance metadata, worldwide. We tested what's actually there today and take apart four claims about what the watermark can do.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/claude-new-watermark.png" /><media:content medium="image" url="https://www.ombulabs.ai/blog/claude-new-watermark.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI for Small Business Starts With People</title><link href="https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people.html" rel="alternate" type="text/html" title="AI for Small Business Starts With People" /><published>2026-08-10T12:23:43-04:00</published><updated>2026-08-10T12:23:43-04:00</updated><id>https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people</id><content type="html" xml:base="https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people.html"><![CDATA[<h2 id="ai-adoption-is-more-about-culture-than-tools">AI Adoption Is More About Culture Than Tools</h2>

<p>A few weeks ago, we sat down with a potential client for a project kickoff conversation. The goal was to find out how we could help them integrate AI into their company. Their whole team joined the call, about 15 people, and within the first ten minutes, it was clear everyone had a different idea of what “using AI” meant to them.</p>

<p>One person wanted an easier way to schedule meetings. Another wanted AI to summarize call notes. A few wanted to search through years of client history. Some of what people described, honestly, was closer to plain automation than a need for artificial intelligence. More than a few people in the room were hesitant to bring AI anywhere near client data.</p>

<p>We see versions of this conversation often. Getting a small team to actually use AI well has less to do with picking the right tool and more to do with getting everyone talking about it the same way, and rolling it out based on collective priorities. In this article, we’ll look at what actually gets in the way of AI adoption, and what it takes to get everyone on the same page.</p>

<!--more-->

<h2 id="adoption-rarely-follows-a-clean-plan">Adoption Rarely Follows a Clean Plan</h2>

<p>We too are a small company, and when ChatGPT first became available, people on our team started experimenting with it individually, mostly for writing emails, Slack messages, and other everyday communication. Some team members also found it especially useful for polishing blog posts before publishing. It wasn’t long before our Operations team confirmed that using AI to help write blog posts was an approved use case, as long as it was being used responsibly. That became one of our first shared, agreed-upon use cases for AI.</p>

<p>Around that time, I was pairing with our founder while he was doing quality assurance on blog posts, and I noticed he was using AI a little differently. He wasn’t using it to generate a draft, but to review one from a few different angles before it went out. It was a small moment, but it stuck with me. There were more interesting ways to use AI than what most of us were doing, and at that point they weren’t something the rest of the team really knew about. I realized that while it may seem obvious to the person who is using AI in that way, it may not be obvious to everyone else. From then on I was always thinking about how knowledge sharing would be one of our greatest tools for incorporating AI.</p>

<p>On the engineering side, adoption moved on its own track. A few engineers asked for GitHub Copilot, while others kept writing code the way they always had. As tools like Claude Code showed up, a handful of people started experimenting with those too, on their own schedule.</p>

<p>I gave an internal presentation around this time on using AI responsibly, mostly focused on avoiding sharing sensitive client data, but I also mentioned some of the ways I had seen our team using it during various pair sessions. The discussion that followed surfaced a handful of practices and considerations that a lot of us, myself included, hadn’t heard yet. The landscape was moving fast enough that even people paying close attention were catching up in real time.</p>

<p>From there, more people started weaving AI into their day to day work, each in their own way. We eventually settled on a smaller set of approved tools, company managed OpenAI and Claude accounts, with clear guidance that client work needed to stay inside those approved accounts. Often we don’t use AI at all when it comes to client projects. It depends on our agreements with each client. We also started building AI into our own internal tools, and not long after, into projects for clients directly.</p>

<p>One thing that stuck with me from a client meeting was hearing our founder describe our approach to accidental AI mistakes, like someone unintentionally sharing information they shouldn’t have, as “tell, don’t blame.” The point wasn’t to pretend mistakes wouldn’t happen. It was to make sure people felt safe enough to flag them the moment they did, so that we could prevent any further damage if necessary.</p>

<p>As our AI work matured, we started taking on client projects focused specifically on building AI solutions. And even with all of that progress, our team still spans a real range. Some engineers use AI constantly, some are much more cautious about it or opt out entirely, and plenty of people fall somewhere in between.</p>

<h2 id="building-alignment-on-purpose">Building Alignment on Purpose</h2>

<p>There isn’t a single clean framework that gets a team from “everyone has a different idea of what AI means” to “the whole team is using it exactly the same.” Treating alignment as something you build on purpose, rather than something that happens automatically once you pick a tool, seems to matter more than the tool itself.</p>

<p>A few things make that process go more smoothly, based on what we’ve seen work both internally and with clients.</p>

<p>Start by getting people to describe what “using AI” means to them before you talk about specific tools. In that meeting with fifteen people, nobody had actually said out loud what they meant by “using AI,” and that gap is what created the disagreement. A short conversation early on can surface those differences before they turn into confusion later. Of course it is possible that different members of the team use it for different use cases, but it’s important that everyone has the shared knowledge of what’s going on.</p>

<p>Make room for people to ask questions and admit mistakes without getting defensive about it. Our “tell, don’t blame” approach to accidental AI mishaps exists because people are much more likely to flag a problem, like an AI tool touching data it shouldn’t have, if they aren’t worried about getting in trouble for it.</p>

<p>Give people a shared vocabulary and a couple of sanctioned starting points. Blog writing became one of our first agreed-upon use cases for AI because it was clear, low risk, and something people could point to. Having somewhere concrete to start made individual experimentation easier to build on, instead of leaving everyone to figure things out entirely on their own.</p>

<p>Alignment also means talking honestly about what AI shouldn’t do to the way people show up at work. If someone drafts a client email with AI, we expect it to still sound like the person sending it, and like the company as a whole. We try to hold each other accountable for that, catching it when something feels flattened or off voice, the same way we’d catch a typo.</p>

<p>That kind of accountability only works if people feel safe raising harder questions too, like whether AI is going to change what their job looks like, or replace parts of it outright. We don’t have a tidy answer to that. What we do have is a habit of talking about it directly instead of letting the question sit unspoken in the room.</p>

<h2 id="treat-ai-as-a-team-capability">Treat AI as a Team Capability</h2>

<p>One of the biggest differences we’ve noticed between teams that successfully adopt AI and those that struggle is that the successful teams don’t leave it entirely up to individuals to figure things out.</p>

<p>One engineering team we work with introduced AI gradually, one workflow at a time. They didn’t expect everyone to suddenly change how they worked overnight. Instead, they rolled out new practices deliberately, made sure everyone understood how to use them, talked openly about where AI was helpful and where it wasn’t, and reinforced that AI suggestions still required human judgment.</p>

<p>Just as importantly, everyone was expected to use the same process. There wasn’t one developer embracing AI while another ignored it entirely. By making AI part of the team’s workflow instead of an individual preference, they created consistency. That consistency made it easier to share what was working, improve their processes over time, and establish common expectations around quality.</p>

<p>What stood out wasn’t the specific AI tools they were using. It was the culture they built around them. AI wasn’t treated as a shortcut. It was treated as another skill the team was developing together.</p>

<h2 id="start-building-organizational-knowledge-early">Start Building Organizational Knowledge Early</h2>

<p>Another pattern we’ve noticed is that the earlier a team starts building shared AI knowledge, the more valuable that knowledge becomes over time.</p>

<p>Many people think about AI adoption as choosing the right model or purchasing the right subscription. In reality, a large part of the value comes from everything your team learns after that decision. Prompts get refined, workflows improve, and internal documentation grows as people share what they’ve learned. People discover better ways to solve problems and share those discoveries with one another.</p>

<p>Over time, that knowledge becomes part of the organization itself. New employees can build on it instead of starting from scratch. Teams begin improving existing workflows instead of reinventing them.</p>

<p>The longer a team waits to start that process, the wider the gap becomes. It isn’t just a gap in familiarity with AI tools. It’s a gap in organizational knowledge. Teams that start earlier have had more time to develop shared practices, build internal context, and improve the systems that support their work.</p>

<p>That doesn’t mean every company needs to move as quickly as possible or adopt every new AI tool that comes along. It does mean there is value in starting small, learning together, and letting that knowledge compound over time.</p>

<h2 id="conclusion">Conclusion</h2>

<p>One thing we’ve learned over the past few years is that AI adoption is never really finished.</p>

<p>Even after we settled on a smaller set of approved tools, new practices and considerations keep surfacing. The tools continue to evolve, new capabilities appear every few months, and teams continue discovering better ways to use them.</p>

<p>The small companies getting the most value from AI aren’t necessarily the ones using the newest models or the most sophisticated tools. They’re the ones creating shared expectations, documenting what works, improving their processes together, and treating AI as something the organization learns, not just something individual employees experiment with.</p>

<p>Think back to that fifteen-person kickoff call. The disagreement in the room wasn’t really about AI at all. It was a team that had never stopped to build the shared vocabulary and expectations we’ve been describing throughout this article. That’s the gap worth closing before you pick a single tool. And once that tool or set of tools is picked, make sure that everyone on the team knows how to knowledge share and is onboard with where you are going.</p>

<p>None of this replaces the work of figuring out which AI problems are actually worth solving for your business. We’ve <a href="https://www.ombulabs.ai/blog/finding-the-right-problems-to-solve-with-ai">written about that separately</a>. But it’s hard to get any use case off the ground if your team isn’t talking about AI the same way to begin with.</p>

<p>The tools will keep changing. The teams that build a culture of learning, knowledge sharing, and thoughtful experimentation will be in the best position to adapt right alongside them.</p>

<p>Have you started introducing AI into your organization, or are you still figuring out where to begin? If you need help, <a href="https://www.ombulabs.ai/contact">let’s talk</a>.</p>]]></content><author><name>fionadl</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[Successful AI adoption isn't just about choosing the right tools. It's about getting your team aligned, building shared processes, and creating a culture where people can learn and improve together.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/ai-for-small-business-starts-with-people.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Guardrail Question to Ask Any AI Vendor</title><link href="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions.html" rel="alternate" type="text/html" title="The Guardrail Question to Ask Any AI Vendor" /><published>2026-08-06T09:34:40-04:00</published><updated>2026-08-06T09:34:40-04:00</updated><id>https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions</id><content type="html" xml:base="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions.html"><![CDATA[<p>You’re evaluating AI vendors for a company-wide rollout, and every conversation ends the same way: it’s safe, it’s anonymized, we have guardrails. We’ve watched this play out recently, and the questions don’t stop at that answer. Safe how, and which guardrails are actually in place to prevent a leak? Vendor reps tend to stall on the specifics: how access is managed at the level of an individual AI agent, not just the account, and which models and model providers are actually involved, with what safety assurances at each one, not because they’re hiding something, but because “we have guardrails” is often as far as the pitch was built to go.</p>

<p>That gap is worth taking seriously, because the mechanism most vendors mean when they say “guardrails” has a specific, documented failure mode. In this post, we’ll look at what that mechanism usually is, what recent research shows about how it fails, and the questions worth asking any AI vendor before “we have guardrails” is enough of an answer.</p>

<!--more-->

<h2 id="what-we-have-guardrails-usually-means">What “we have guardrails” usually means</h2>

<p>Ask a vendor what “guardrails” means, and you’ll usually get a version of the same answer: the AI’s actions get reviewed by something else before or after they happen. Sometimes that’s a human in the loop. Increasingly, especially for an agent handling requests continuously, it’s another AI model watching the first one and flagging anything that looks wrong.</p>

<p>What that second model is actually watching is the part vendors rarely spell out. In many implementations, the reviewer isn’t looking at what the AI did. It’s looking at what the AI said it was doing, the explanation or reasoning it wrote out for itself before or alongside the action. That’s a convenient thing to review: it reads like a sentence, and a human or a second model can skim it for a verdict without inspecting every downstream effect. It’s also a much easier thing to fake, because the model producing that explanation is the same model whose motives the guardrail exists to check in the first place.</p>

<h2 id="the-guardrail-can-be-lied-to">The guardrail can be lied to</h2>

<p>That gap, between what the AI did and what it said it was doing, is exactly what a new paper, <a href="https://arxiv.org/abs/2608.00583">“A False Average: Chain-of-Thought Monitors Collapse Where They Are the Only Defense”</a>, went after. The researchers attacked the AI’s own explanation of its actions while leaving the actions themselves untouched, then measured whether a monitoring layer built to catch bad behavior, specifically an AI gaming its own reward signal instead of doing what was actually intended, still caught it.</p>

<p>Before the attack, the monitor caught the bad behavior in around 95% of cases. After, detection fell to under 11%, and the drop held up across different kinds of monitors and different underlying AI models, not just one setup. The researchers describe the 95% figure as a “false average”: it’s an accurate number across situations where a monitor has other signals available, but in the one situation where the explanation is the only signal, the number that actually applies is under 11%, not 95%.</p>

<p>Worth noting: this is one study, published within the last week, and the authors haven’t released code to reproduce it. Treat the specific numbers as evidence of a real failure mode, not as a settled, precisely-calibrated result.</p>

<h2 id="splitting-a-request-across-sessions">Splitting a request across sessions</h2>

<p>The reasoning-trace study looked at a single interaction. A second paper, <a href="https://arxiv.org/abs/2608.01117">“SoK: Intent-Oriented Systematization of Multi-Turn LLM Jailbreaks”</a>, published the same week, looked at what happens across a longer back-and-forth, and it matters just as much for anyone asking how access is managed at the level of an individual AI agent, not just the account.</p>

<p>The researchers organized jailbreak attempts, conversations engineered to talk a model into doing something it’s supposed to refuse, by how the adversarial intent gets distributed: laid out in one request, or spread piece by piece across several messages. What predicted success wasn’t how long the conversation ran, it was how the objective was organized. One category stood out: splitting the goal across multiple sessions rather than one continuous conversation defeats any safety check that resets its state at the start of each session. That’s not an edge case. Treating each request or each session as a clean slate is the easiest way to implement a check, and it’s also the version this research shows falling apart under exactly this kind of pressure.</p>

<p>If a vendor tells you access is “managed,” the useful follow-up is whether that management carries any memory across sessions, or whether it’s re-deciding from scratch every time a new conversation starts.</p>

<h2 id="when-benign-interactions-compound">When benign interactions compound</h2>

<p>The session-splitting finding assumes someone is deliberately spreading out an attack. A third paper, <a href="https://arxiv.org/abs/2608.01759">“Benign Alone, Harmful Together: Exploiting Experience Composition in Self-Evolving LLM Agents”</a>, shows the same failure mode can happen without anyone trying to defeat anything on purpose.</p>

<p>The setup: an AI agent with persistent memory, meaning it remembers past interactions and carries that context forward rather than starting fresh each time. The researchers built a sequence of interactions where each one, judged individually, looked entirely reasonable, nothing that would trip a review at the moment it happened. Composed together over time, though, that sequence produced an unsafe outcome. A safety check that reviews each new piece of information as it comes in, on its own, has no way to catch this by design, because there’s never a single moment where anything looks wrong.</p>

<p>This is the sharpest version of the same underlying problem: a guardrail built to inspect one thing at a time, one action, one session, one message, is answering a narrower question than “is this system safe,” and a vendor who can’t describe how they check for the version that unfolds over time hasn’t actually answered it.</p>

<h2 id="openais-own-disclosure">OpenAI’s own disclosure</h2>

<p>Everything so far comes from research. On August 4, 2026, OpenAI published <a href="https://openai.com/index/third-party-cyber-evaluations-involving-openai-models/">“Third-party cyber evaluations involving OpenAI models”</a>, something less abstract: two recent third-party evaluations where a model reached past the boundary the evaluation was supposed to enforce, and neither one required the model to defeat a hardened sandbox.</p>

<p>In one, a misconfiguration left the test environment connected to the public internet, and the model found a real website that happened to share a name with its fictional CTF target and used it, credentials included. OpenAI is explicit that this wasn’t a sandbox escape or a zero-day, the boundary had simply never been closed. In the other, the model reused a leaked GitHub token and tunneled a local service out to the internet. OpenAI attributes part of the cause to the evaluation itself never telling the agent that open internet access was off-limits. Neither is the dramatic version of this story, a model breaking out of a hardened container. It’s the less comfortable version: the access was already there, and the model used it.</p>

<p>If the company building the model can have this happen inside its own evaluations, “the vendor sandboxes it” isn’t enough of an answer on its own either. A boundary that exists on paper, or as an instruction the model is expected to follow, isn’t the same as one enforced by default: scoped access, no open network egress unless explicitly allowed, not just a model that’s merely expected to stay inside the lines.</p>

<h2 id="what-to-ask-before-you-trust-a-guardrail">What to ask before you trust a guardrail</h2>

<p>None of this means every guardrail is worthless, or that monitoring has no place in a safety architecture. It means “we have guardrails” describes a category, not a specific implementation, and the difference between implementations is exactly what the last few sections covered. (If you’re still earlier in the process, deciding how AI tools fit into your organization’s data handling at all, we’ve covered that separately in <a href="https://www.ombulabs.ai/blog/ai-graduated-privacy-and-safety">“Safely Leveraging AI: Privacy and Security”</a>.) Once you’re at the vendor conversation specifically, a short list of questions gets at the difference faster than a marketing page will:</p>

<ul>
  <li>Is the check independent of the model it’s checking? A monitor built from the same model family being reviewed, or one that only sees the model’s own explanation of itself, is checking a much softer signal than one that inspects the model’s actual outputs, tool calls, or side effects independently.</li>
  <li>Does it look at what the AI actually did, not just what it said it was doing? Ask specifically whether the review has access to an audit log of real actions (files touched, requests made, data moved) versus a summary the model generated about itself.</li>
  <li>Does it survive a long or split-up conversation? A check that resets at the start of every session or every request is checking a narrower slice of the interaction than a check that carries context across the whole thing.</li>
  <li>How is access managed at the level of an individual AI agent, not just at the account level? An account-level permission doesn’t tell you what a specific automated agent acting under that account can actually reach.</li>
  <li>Which models and model providers are actually involved, and what safety assurances exist with each of them? A vendor’s own guardrails sit on top of whatever the underlying model provider offers (or doesn’t), and that’s a second, separate set of commitments worth asking about directly.</li>
</ul>

<p>These aren’t gotcha questions. They’re the specific version of “is this safe” that the research above shows actually matters, and a vendor with a real answer to each one has done meaningfully different work than a vendor with a slide that says “guardrails” on it.</p>

<h2 id="conclusion">Conclusion</h2>

<p>“We have guardrails” is a real answer, just not a complete one. That phrase usually means a review layer that watches an AI’s explanation of itself rather than its actions, and three pieces of research from the same week show that explanation can be manipulated, that safety checks resetting per session can be walked around by splitting a request across a longer conversation, and that even individually harmless interactions can add up to an unsafe outcome in a system with memory. OpenAI’s own disclosure that two of its recent evaluations relied on boundaries that were assumed rather than enforced, and got crossed as a result, is the reminder that none of this is theoretical.</p>

<p>All of this research is new, some of it published within the last week, and none of it means monitoring is worthless or that every vendor claiming guardrails is overselling. It means “guardrails” describes a category with a wide range of actual implementations, and the difference matters enough to ask about before you sign a contract, not after.</p>

<p>That’s a different question from the one we covered in <a href="https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist">our EU AI Act Article 50 piece</a>: that one is about what you’re required to disclose to users. This one is about whether the mechanism keeping the system safe actually holds up, regardless of what you disclose.</p>

<p>Evaluating an AI vendor and not sure which questions actually matter? <a href="/#contact-us">Let’s talk</a>.</p>]]></content><author><name>abizzinotto</name></author><category term="security-and-privacy" /><summary type="html"><![CDATA[You’re evaluating AI vendors for a company-wide rollout, and every conversation ends the same way: it’s safe, it’s anonymized, we have guardrails. We’ve watched this play out recently, and the questions don’t stop at that answer. Safe how, and which guardrails are actually in place to prevent a leak? Vendor reps tend to stall on the specifics: how access is managed at the level of an individual AI agent, not just the account, and which models and model providers are actually involved, with what safety assurances at each one, not because they’re hiding something, but because “we have guardrails” is often as far as the pitch was built to go. That gap is worth taking seriously, because the mechanism most vendors mean when they say “guardrails” has a specific, documented failure mode. In this post, we’ll look at what that mechanism usually is, what recent research shows about how it fails, and the questions worth asking any AI vendor before “we have guardrails” is enough of an answer.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/ai-vendor-guardrail-questions.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">EU AI Act Article 50: The SMB Checklist</title><link href="https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist.html" rel="alternate" type="text/html" title="EU AI Act Article 50: The SMB Checklist" /><published>2026-08-04T07:43:54-04:00</published><updated>2026-08-04T07:43:54-04:00</updated><id>https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist</id><content type="html" xml:base="https://www.ombulabs.ai/blog/eu-ai-act-article-50-smb-checklist.html"><![CDATA[<p>If your business runs a support chatbot, or if someone on your team uses an AI tool to draft marketing copy, you already have an AI system in scope of the <a href="https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng">EU AI Act’s transparency rules</a>. You don’t need to be doing anything exotic or high-risk to be covered. As of August 2, 2026, <a href="https://digital-strategy.ec.europa.eu/en/news/commission-starts-enforcing-ai-act-rules-and-new-transparency-requirements-2-august">these rules are binding, and the European Commission’s AI Office, together with national market surveillance authorities, can enforce them</a>.</p>

<p>That catches a lot of businesses off guard, because most of the coverage of the AI Act so far has focused on high-risk systems: hiring tools, credit scoring, biometric surveillance. Those obligations are real, but they’re not due for a while yet, more on that below. The transparency tier is different: it applies now, and it applies broadly, to providers and deployers whose systems are placed on the EU market or whose output reaches EU users, so a US business with EU clients or site visitors is in scope too.</p>

<p>In this post, we’ll cover who’s actually in scope of Article 50’s transparency rules, the concrete steps you need to take, and what’s genuinely still years away.</p>

<!--more-->

<h2 id="whos-actually-in-scope">Who’s actually in scope</h2>

<p>Most of what got written about the EU AI Act in 2024 and 2025 focused on its risk tiers: unacceptable risk (banned outright), high-risk (strict conformity requirements), and everything else. If you weren’t building a hiring algorithm or a credit-scoring model, it was easy to conclude the Act didn’t apply to you.</p>

<p>Article 50 doesn’t work off that classification. It’s a separate, horizontal layer of obligations that applies based on what an AI system does, not how risky regulators judged it to be. A system that interacts directly with people, like a chatbot or a virtual agent, is in scope. A system that generates or manipulates content, text, image, audio, or video, is in scope, whether or not a person reviews the output before it goes out. A system that performs emotion recognition or biometric categorization is in scope. None of these is classified as high-risk under the Act’s own tiers, and all three are covered by Article 50 regardless.</p>

<p>A support chatbot on your website is the first category. AI-drafted marketing copy or blog content is the second. Neither reads as “high-risk AI” to most SMB owners, which is exactly why this tier catches people who assumed they were in the clear.</p>

<p>Scope isn’t limited to companies established in the EU, either. The obligations reach any provider or deployer whose AI system is placed on the EU market, or whose output is used by people in the EU. A US-based business with EU customers interacting with its chatbot, or an EU audience reading its AI-drafted content, is in scope on that basis alone.</p>

<h2 id="what-article-50-requires">What Article 50 requires</h2>

<p>Per <a href="https://digital-strategy.ec.europa.eu/en/news/commission-publishes-guidelines-transparency-obligations-providers-and-deployers-certain-ai-systems">the Commission’s guidelines on Article 50</a>, the obligations break down by what your system does and who you are in the transaction: provider (the business that builds or supplies the AI system) or deployer (the business that uses it). For most SMBs reading this, you’re a deployer: you didn’t build the chatbot or the generative model, you’re running one someone else built.</p>

<p>In practice, that means:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">If you have…</th>
      <th style="text-align: left">You must…</th>
      <th style="text-align: left">Format</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">A chatbot or AI agent that talks to customers</td>
      <td style="text-align: left">Disclose that it’s AI, unless already obvious from context</td>
      <td style="text-align: left">Clear disclosure</td>
    </tr>
    <tr>
      <td style="text-align: left">AI-generated text, image, audio, or video</td>
      <td style="text-align: left">Mark it as artificially generated</td>
      <td style="text-align: left">Machine-readable</td>
    </tr>
    <tr>
      <td style="text-align: left">Deepfake-style media resembling real people, places, or events</td>
      <td style="text-align: left">Visibly label it</td>
      <td style="text-align: left">Visible label</td>
    </tr>
    <tr>
      <td style="text-align: left">AI-generated text on matters of public interest</td>
      <td style="text-align: left">Flag it as AI-generated, unless a human reviewed it and takes editorial responsibility</td>
      <td style="text-align: left">Visible disclosure</td>
    </tr>
    <tr>
      <td style="text-align: left">Emotion recognition or biometric categorization systems</td>
      <td style="text-align: left">Inform the people being assessed</td>
      <td style="text-align: left">Notice at point of use</td>
    </tr>
  </tbody>
</table>

<p>If your chatbot already says “AI Assistant” in its name or interface, you likely already satisfy the first row. If your marketing team publishes AI-drafted blog posts or social copy without a human edit pass, the fourth row is the one to look at first, it’s also the easiest to miss, since “matters of public interest” is broader than it sounds and covers ordinary commentary, not just news reporting. (If AI-drafted marketing copy is new territory for your team, <a href="https://www.ombulabs.ai/blog/ai-graduated-privacy-and-safety">we’ve also covered the privacy and security side of adopting AI tools</a>, which is a separate concern from Article 50 but tends to come up around the same time.)</p>

<p>For the marking and labeling rows, you don’t have to invent a visual system from scratch. The Commission publishes <a href="https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content">a set of standard EU icons for labeling AI-generated content</a>: a basic icon for AI involvement in deepfake or published text, a “fully AI-generated” icon for wholly synthetic content, and a “partially AI-modified” icon for human content that’s been altered with AI. Using these specific icons is optional, but whatever marking you use has to meet the same bar: it needs to be clearly perceivable at the point someone first encounters the content, embedded directly in it rather than living in a separate disclaimer page, and it has to survive the content being reshared or downloaded. The icons themselves are free, available as SVG or PNG, in black or white with optional transparency, which makes them a reasonable default if you don’t want to design your own.</p>

<p>For the other two rows, disclosing a chatbot and notifying people subject to emotion recognition or biometric categorization, the guidelines don’t prescribe an exact format. A clear statement in the interface satisfies the chatbot disclosure; there’s no required certification or specific wording.</p>

<p>There’s one exception worth knowing about if you were already running a generative AI system before August 2, 2026: providers get a four-month transitional period to adapt existing practices to the new marking requirements. The obligation itself isn’t delayed, the grace period only covers systems that were already on the market. If you’re deploying something new now, it doesn’t apply to you.</p>

<h2 id="the-cheap-compliance-route">The cheap compliance route</h2>

<p>You don’t have to work out how to satisfy Article 50 from scratch. The European Commission published a <a href="https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content">Code of Practice on Transparency of AI-generated Content</a> specifically to give providers and deployers a pre-approved way to comply: one section covers marking and detecting AI-generated or manipulated content (the provider side), the other covers labeling deepfakes and AI-generated text (the deployer side, which is the one most SMBs will use).</p>

<p>By the end of July 2026, around 190 companies and organizations had signed it. Signing means you can point to the Code’s measures as your compliance approach, rather than building your own justification for a regulator. It also buys predictability: signatories get the same treatment across every EU member state, instead of a patchwork of national interpretations, and get a seat in the taskforces that shape how the Code evolves.</p>

<p>You don’t have to sign it. But if you don’t, the burden shifts to you: you have to independently demonstrate that whatever alternative measures you built are adequate, and that gets assessed case by case by whichever member state’s market surveillance authority is looking at you. For an SMB without in-house legal counsel, adopting the Code’s existing measures is very likely the cheaper path, both in setup cost and in what happens if a regulator ever asks.</p>

<h2 id="whats-not-due-yet">What’s not due yet</h2>

<p>The AI Act’s high-risk tier obligations, the ones for hiring algorithms, credit scoring, and similar systems under Annex III and Annex I, got a real timeline change this year. The <a href="https://eur-lex.europa.eu/eli/reg/2026/1744/oj/eng">Digital Omnibus</a> (Regulation (EU) 2026/1744, in force since July 27, 2026) pushed the Annex III deadline (AI systems classified as high-risk under Article 6(2)) from August 2, 2026 to December 2, 2027. It pushed the Annex I deadline (product-embedded high-risk systems under Article 6(1)) from August 2, 2027 to August 2, 2028.</p>

<p>If your business isn’t in one of those high-risk categories, this section doesn’t change anything for you, Article 50 still applies now, as covered above. But it’s worth knowing about for two reasons. First, if a consultant or a client asks you about AI Act “deadlines” broadly, they may be thinking of these dates, not Article 50’s. Second, a lot of what’s published online about the AI Act’s timeline hasn’t caught up with the Omnibus yet.</p>

<p>We checked one of the more commonly cited trackers, <a href="https://artificialintelligenceact.eu/implementation-timeline/">artificialintelligenceact.eu’s implementation timeline</a>, while writing this. As of this writing, it states: “2 August 2027: Application: Article 6(1) and the corresponding obligations in the Regulation start to apply,” with no mention of the Omnibus or Regulation (EU) 2026/1744 anywhere on the page. That’s the pre-Omnibus date. The actual date is now a year later, August 2, 2028. If you’re relying on a bookmarked timeline page instead of checking the regulation directly, you may be working from a schedule that’s already out of date.</p>

<h2 id="penalties-and-enforcement">Penalties and enforcement</h2>

<p>Enforcement sits mainly with national market surveillance authorities in each member state; the AI Office’s own enforcement role is narrower, reaching only systems built on general-purpose AI models where the same company is both provider and model-maker, or systems folded into a DSA-designated very large online platform or search engine. As of August 2, 2026, both have the authority to act on Article 50 violations, not just issue guidance.</p>

<p>The headline number is up to €15 million or 3% of global annual turnover, whichever is higher, for non-compliance with the transparency obligations. EU institutions, bodies, and agencies face a separate, lower ceiling of €750,000.</p>

<p>If you’re an SME or a small mid-cap company, including a startup, the Act caps your fine at whichever of the two figures is lower, the percentage or the fixed amount, not whichever is higher, as applies to larger companies. In practice, that makes 3% of turnover the real ceiling for most small businesses, not €15 million. The obligations still apply in full either way, this only changes what a violation can cost you.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This should give you a very good idea of who’s actually in scope of Article 50 (probably you, if you’re running a chatbot or generating AI content), the five things the transparency tier actually requires, the Code of Practice as the lower-effort way to demonstrate compliance, and the higher-risk obligations that are still years away regardless of what an old bookmark might say.</p>

<p>None of this is legal advice, and guidance in this area is still evolving even as enforcement starts. If your business touches any of the five categories above, the practical next step is to check your own systems against them directly, rather than assuming a chatbot or a content workflow is too small to count. And if you’re still deciding where AI belongs in your business in the first place, that’s a question worth answering before a compliance one, <a href="https://www.ombulabs.ai/blog/finding-the-right-problems-to-solve-with-ai">we’ve written about finding the right problems to solve with AI</a> too.</p>

<p>Not sure whether your chatbot or your AI-generated content already meets Article 50? <a href="/#contact-us">Let’s talk</a>.</p>]]></content><author><name>abizzinotto</name></author><category term="ethics-and-responsible-ai" /><summary type="html"><![CDATA[Article 50's transparency rules are already binding, EU-based or not. Here's who's in scope, what to disclose, and the SME penalty scale that actually applies.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/eu-ai-act-article-50.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/eu-ai-act-article-50.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI Assistant for Our Blog Writing Process</title><link href="https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process.html" rel="alternate" type="text/html" title="AI Assistant for Our Blog Writing Process" /><published>2026-07-30T15:08:22-04:00</published><updated>2026-07-30T15:08:22-04:00</updated><id>https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process</id><content type="html" xml:base="https://www.ombulabs.ai/blog/ai-assistant-blog-writing-process.html"><![CDATA[<p>Our blog posts are written by the people who did the work. The process is simple: someone picks a topic, writes a draft, and opens a pull request. From there it goes through two passes, a review that looks for correctness issues, gaps, and overstated claims, followed by a QA pass that checks the final polish: that the whole thing ties together, there are no typos, images render correctly, and links go where they say they go.</p>

<p>Like a lot of teams, we started leaning on AI to help our authors get ideas onto the page. Posts get written faster and people get past their writer’s block quicker. It comes with its downsides though. Asked to fill in a gap, it will make an assumption. Asked to make a paragraph read better, it will rewrite a carefully hedged sentence into a cleaner one that means something slightly different, or expand it into a claim that can’t be sustained. So AI helped our authors move faster, but it created a good deal more work for the reviewers. It didn’t remove a bottleneck, it moved it downstream.</p>

<p>To help with this, I built an assistant for our blog writing process. In this article, I’ll talk through why I built it, how I decided what it should be, and what it has done for our writing process so far.</p>

<!--more-->

<p>Solving the AI problem with more AI is a bit counterintuitive, but some battles we are not going to win, so it’s better to make it work for us than to try to prevent it from being used. The useful thing to build here was a properly constrained version of the tool people were already using. It has two pieces: an <a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro">MCP (Model Context Protocol) server</a> that knows our blogs: what we have published and how we write, and a <a href="https://claude.com/product/claude-code">Claude Code</a> plugin the team uses to suggest topics, plan a post, draft it and review it before a pull request is ever opened. The whole thing took about ten hours to put together, which is most of the point. It was cheap, it was easy, and so far it looks like it’s working. AI solutions don’t have to be complex, and not everything needs to be a custom agent.</p>

<h2 id="the-problem-to-solve">The problem to solve</h2>

<p>We are committed to the quality of our content, and reviews are thorough and done by experts on the topic. Naturally, for some topics, we have fewer reviewers than authors. This means what looked like a productivity gain driven by AI was in fact a productivity decrease. Reviewers received more posts, saw more things to flag, posts saw more rounds of reviews, reviews took longer. That came back around to the authors too: more feedback to work through and more back and forth before a post could ship.</p>

<p>Not only that, but the style of writing started to shift. AI introduced its own way of writing and organizing information, and most reviewers won’t flag every little instance of this happening. It feels like a nitpick, and at the end the content reads just fine. This is a problem though. Our content stopped sounding like us, like our voice.</p>

<p>On top of that, with more than 400 articles published across <a href="https://www.ombulabs.ai/blog">OmbuLabs.ai</a> and <a href="https://www.fastruby.io/blog">FastRuby.io</a>, it’s very hard for one person to keep track of everything we’ve published. So cross-references got missed, redundant content got shipped, and opportunities for creating a series or deepening a topic never surfaced.</p>

<h2 id="why-an-assistant">Why an assistant</h2>

<p>We’re no stranger to automation. In fact, we’ve built a few internal tools on that basis: <a href="https://www.ombulabs.ai/blog/ai-assisted-marketing">an LLM assistant</a> that drafts the article summaries for the <a href="https://www.fastruby.io/newsletter">FastRuby.io newsletter</a>, <a href="https://www.ombulabs.ai/blog/pecas-business-case">Pecas</a>, which flags problems in our time entries, and another one that handles <a href="https://www.ombulabs.ai/blog/automating-quarterly-software-maintenance-reports-with-llm">our quarterly maintenance reports</a>. We believe that humans should spend time on the things that need a human brain, and automation should take care of the boring bits.</p>

<p>This problem was no different. The accuracy issues were eating reviewer time and the style issues were slipping through, and both were landing on the same people who were already the bottleneck. What we needed to do was:</p>

<ul>
  <li>Cut down the time our reviewers were spending on each post;</li>
  <li>Improve the quality of drafts before they ever got to review;</li>
  <li>Do it in a way that let our authors keep using AI wherever they find it helpful, because they’re going to either way, and because it does help.</li>
</ul>

<p>None of that is a writing problem, so a tool that writes better wasn’t going to fix it. What we needed was a tool that knows things: what we’ve already published, what our house style actually is (rather than what we’d say it is if someone asked), and which sentences in a draft are making claims that somebody needs to check.</p>

<h2 id="deciding-what-to-build">Deciding what to build</h2>

<p>Knowing what we needed didn’t tell me what to build. There are a lot of different ways to solve the problem, and when Large Language Models (LLMs) are involved, there are a lot of fancy, complex ways that sound appealing (but ultimately aren’t worth it). I explored a few different possibilities:</p>

<h3 id="a-reviewer-running-in-ci">A reviewer running in CI</h3>

<p>We already have a tool like this that checks for grammar issues and typos. Plugging something into that would be a viable option.</p>

<p>This was quickly discarded though. It isn’t interactive enough, it’d need to focus on the review aspect and catch issues after they are there. Also, to make sure issues wouldn’t slip through, the goal was to make the AI reviewer very nitpicky. This would result in a lot of comments in the PR that nobody has the time to go through and hurt the conversation that can happen between two people in the PR itself. In other words, it makes things worse for both author and reviewer.</p>

<h3 id="a-well-crafted-prompt">A well-crafted prompt</h3>

<p>Write it once, share it with the team. Simple enough, cheap, and it’s where the whole thing starts, how you prompt the LLM.</p>

<p>However, the issue we have at hand is dynamic in nature, and the shared prompt would be static. It’d need to have categories, existing keywords, and references hard-coded into it. All that is stale the minute a new keyword is added, a category is added, or references shift with time.</p>

<p>It also has no concept of related posts or per-author style and expertise, so it doesn’t do much to help find a topic, flesh out an idea, or even review a post by a particular author. A lot of maintenance for a subpar result.</p>

<h3 id="a-custom-agent">A custom agent</h3>

<p>We hear this a lot. I have this problem, I think AI can help solve it, I need an agent. Agents are dynamic, can tackle complex tasks, can call tools and access external data, perfect for this. This would have, in fact, been a valid solution. But an agent needs a place to live, it needs maintenance, and it needs a significant amount of upfront work to work as intended.</p>

<p>Our workflows aren’t complex, the amount of data involved in this is small, and it really just needs to make the tool people are already using better. Anthropic built us a perfectly good agent, we just needed to make it a little bit more specialized. It needs data, not custom pipelines, workflows, and complex actions.</p>

<h2 id="what-we-built">What we built</h2>

<p>The last point settled the solution: let’s build something within Claude Code that extends its knowledge of our domain without relying on people telling it stuff. There are several ways to do that, and two of them fit what I needed: an <a href="https://code.claude.com/docs/en/mcp">MCP server</a> for giving it access to our data, and a <a href="https://code.claude.com/docs/en/plugins">plugin</a> for packaging up the workflows and shipping them to the team. So that’s what I built, a plugin and an MCP server.</p>

<p>Nothing in life is as easy as it sounds though. In order for the MCP to be effective, we needed the data it’d be exposing to be structured and available. As happens in most of these cases, it wasn’t.</p>

<p>So the first thing I built was a very simple data pipeline. It pulls the markdown files from both blogs’ GitHub repositories, reads the block of metadata at the top of each one, and loads the posts into a Postgres database along with their categories, content types, keywords and authors. It also creates an embedding for each post, a numeric summary of what that post is about (stored with <a href="https://github.com/pgvector/pgvector"><code class="language-plaintext highlighter-rouge">pgvector</code></a>, a Postgres extension built for this kind of data). Two posts that cover similar ground end up with similar numbers, which is what lets the assistant find related posts by meaning rather than by matching keywords.</p>

<p><img src="/blog/assets/images/blog-assistant-architecture.svg" alt="Diagram of the blog assistant architecture" /></p>

<p>On top of that database sits the MCP server, which exposes tools, meaning things the assistant can do, and resources, meaning documents it can read. The taxonomy resource is generated live from the database, so the list of valid categories, content types and keywords is whatever we are actually using today rather than something written down once and left to go stale. This makes it much more useful than just asking Claude to do a web search against our blogs. It allows it to see all available categories, top keywords, find the posts closest to a draft, pull stats on the content, and so on. It also exposes tools like style checks, looks for common antipatterns, and tools that validate the metadata at the top of a post.</p>

<p>The plugin complements it by holding the workflows. It determines the steps to follow to suggest a topic, plan a post, draft one, and then review it. It works the same for every author, it pulls from one source of truth, but it still has author-specific context available to it.</p>

<p>The split matters. Anything that is true about the blogs lives on the server, so when a style rule changes or a new pattern to look for needs to be added, the change applies to everyone at once. And when something in how we work changes, or we want to improve a workflow, the change applies to the plugin through a pull request like any other, and everyone gets it at the same time.</p>

<p>For an author, this means they can get better, more relevant topic suggestions, higher quality drafts, and less back and forth on reviews as issues get flagged early. Human reviewers are not replaced, we still need an expert to review the post and catch anything the tool can’t. It just helps reduce the number of things left for them to catch, and how much slips through.</p>

<p>Apart from the metadata validation, none of what the automated reviewer flags is a hard rule. Issues flagged in review can be ignored and the post will move forward all the same. The author still decides.</p>

<h2 id="what-it-cost">What it cost</h2>

<p>The whole point here was simplicity. A quick but effective solution for a problem that’s been bugging us for months.</p>

<p>Those ten hours were a flight from Atlanta to São Paulo (so it’s not like I had anything better to do anyway), and this was enough because of everything I left out. There’s no custom interface, the interface is Anthropic’s problem. There’s no user management, because access to the repository is access to the plugin. There’s no orchestration layer, nothing coordinating a chain of steps, because the skills are markdown files that describe what to do and in what order (we’ve written about <a href="https://www.ombulabs.ai/blog/how-to-write-a-claude-code-skill">how to write a Claude Code skill</a> before). Nearly every part of this that would have taken real time to build is a part I decided I didn’t need.</p>

<p>On the other side of the ledger, we expect this to cut the time spent reviewing a post, and the back and forth that comes with it, by something in the region of thirty to forty percent. The early results have been better than that, but we’re being careful with them: it’s a small number of posts so far, and those posts had already been through part of our usual process before the tooling saw them, so the comparison flatters the tool. We’re treating the early numbers as encouraging rather than settled. Even at the conservative end, the time being saved belongs to the people who are hardest to free up, and ten hours is not a lot to spend to get some of it back.</p>

<h2 id="conclusion">Conclusion</h2>

<p>There’s nothing especially sophisticated about this assistant. It’s a data pipeline, a database, an MCP server that exposes what’s in it, and a handful of markdown files describing how we work. Ten hours and around $2,000 worth of time, against the weeks and the tens of thousands a custom agent would have cost to build and keep running. Most of the effort went into working out what those pieces needed to do, and that part had very little to do with AI.</p>

<p>One thing worth being explicit about though is that this worked because our writing process was already well-established. We knew what the steps were, who did them and where they got stuck. Automating a workflow you don’t understand very well tends to produce a tool nobody uses or, worse, one that makes the confusion happen faster. If you’re looking at your own process and wondering where AI fits into it, that’s the part to get right first. We go deeper into that in <a href="https://www.ombulabs.ai/blog/finding-the-right-problems-to-solve-with-ai">Finding the Right Problems to Solve with AI</a>.</p>

<p>Got a workflow where AI would obviously help, but you’re not sure what to actually build? <a href="/contact">Let’s talk!</a></p>]]></content><author><name>abizzinotto</name></author><category term="ai-agents" /><summary type="html"><![CDATA[Not every AI solution needs to be a custom agent. How I built an MCP server and Claude Code plugin for our blog writing process in about ten hours.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/ai-assistant-for-our-blog-writing-process.jpg" /><media:content medium="image" url="https://www.ombulabs.ai/blog/ai-assistant-for-our-blog-writing-process.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Turning User Ideas into AI-Generated Designs</title><link href="https://www.ombulabs.ai/blog/from-input-to-product.html" rel="alternate" type="text/html" title="Turning User Ideas into AI-Generated Designs" /><published>2026-07-28T10:03:44-04:00</published><updated>2026-07-28T10:03:44-04:00</updated><id>https://www.ombulabs.ai/blog/from-input-to-product</id><content type="html" xml:base="https://www.ombulabs.ai/blog/from-input-to-product.html"><![CDATA[<p>Over the past six months at OmbuLabs.ai, we’ve had the opportunity to work on several projects involving generative AI for designing and enhancing real world products.</p>

<p>With today’s image generation models, it might seem like this should be straightforward. A user describes what they want, an image generation model creates a design, and you’re done.</p>

<p>In practice, building an application that consistently produces useful product designs is much more involved. The image generation model is only one part of the solution. The real challenge is designing the application around the model so that it has the information and context it needs to generate useful results.</p>

<p>There is no single way to build these kinds of systems. Every project presents different challenges, different sources of domain knowledge, and different technical constraints. Throughout development, we found ourselves making a series of decisions. Where should different types of information live? Should it become part of a prompt? Should it be retrieved from a knowledge base? Should it be provided through a tool or reference image? Which image generation model best supports the workflow we want to build?</p>

<p>In this post, we’ll walk through how we approached those decisions. We’ll look at the system that transforms a user’s request into a design that better reflects a company’s products, expertise, and constraints.</p>

<!--more-->

<h2 id="the-illusion-of-easy-ai-design">The Illusion of “Easy” AI Design</h2>

<p>To make this concrete, let’s imagine a company called SneakerCompany. They are a direct-to-consumer brand that wants to let customers design their own sneakers using AI.</p>

<p>A customer enters a request like:</p>

<blockquote>
  <p>“A modern sneaker with a premium feel, neutral tones, and subtle texture.”</p>
</blockquote>

<p>An image model generates a sneaker, and you’re done, right?</p>

<p>If the goal is simply to create an interesting image, maybe.</p>

<p>If the goal is to generate a product that could actually be manufactured, that single request needs to become something much richer.</p>

<p>That richness includes a material for the upper (leather, knit, suede), a call on whether the sole is minimal or performance focused, and a concrete definition of what “premium” means for this company. It also has to reflect which materials are approved, which combinations can actually be manufactured, and which finishes align with the brand.</p>

<p>This is the gap between AI-generated images and real product design. Success depends less on the image model itself and more on the system that communicates with the model to generate the right result.</p>

<h2 id="capture-domain-knowledge">Capture Domain Knowledge</h2>

<p>Once we understood the problem we were trying to solve, the next step was understanding what information an application would need.</p>

<p>That knowledge rarely comes from a single source. Depending on the product, it may come from designers, engineers, manufacturing teams, product managers, brand specialists, existing documentation, product catalogs, or years of institutional knowledge.</p>

<p>Each source contributes a different piece of the puzzle.</p>

<p>Some of the questions we started with included things like:</p>

<ul>
  <li>How do experts describe the product when speaking about it?</li>
  <li>What terminology do they use?</li>
  <li>Which combinations should never be generated?</li>
  <li>What materials, colors, or finishes are approved?</li>
  <li>What information exists in a visual form?</li>
  <li>What do users commonly ask for, and how was the company interpreting that before?</li>
</ul>

<p>As we worked through these questions, we realized that not all knowledge is created equal.</p>

<p>Some information rarely changes, like brand guidelines or materials that should never be used.</p>

<p>Some information is highly visual, such as stitching patterns, sole designs, or shoelace styles.</p>

<p>Other information changes regularly, such as product catalogs, trends or historical customer designs.</p>

<p>Understanding the different kinds of knowledge available became just as important as collecting the knowledge itself.</p>

<h2 id="designing-the-system">Designing the System</h2>
<p>Once we understood the information available to us, the next challenge was deciding how the application should use it.</p>

<p>There is no single architecture for AI-powered design applications. Every piece of information raises a different design decision.</p>

<p>While iterating with our clients we began coming up with these kinds of questions for ourselves:</p>

<ul>
  <li>Should a business rule, like a banned sole material, become part of the instructions sent with every request?</li>
  <li>Should thousands of historical sneaker designs be retrieved from a database when needed?</li>
  <li>Should curated images of approved laces and stitching patterns be provided directly to the image generation model as reference images?</li>
  <li>Should the application call a tool to retrieve information that changes frequently, like current shoe trends?</li>
</ul>

<p>These decisions weren’t made in isolation. They were influenced by both the type of information we were working with and the capabilities of the image generation model. As we refined the system, we found ourselves iterating between the architecture and the model. Sometimes a new piece of domain knowledge changed how we wanted to build the application. Other times, the capabilities of the model changed what was possible.</p>

<p>That kind of back-and-forth iteration is only possible if you can see what’s actually happening inside the system, which is why we built <a href="https://www.ombulabs.ai/blog/why-llm-applications-need-tracing.html">tracing</a> in as early as possible. AI applications often have many moving parts, including retrieved context, reference images, tool calls, prompts, and model responses. Having visibility into each step made it much easier to understand why a particular design succeeded or failed, and let us iterate on the system with much greater confidence.</p>

<p>For example, we found that company-wide rules, such as sneaker materials that should never appear in generated products, were a natural fit for the system prompt because they should apply to every request.</p>

<p>On the other hand, large collections of historical product designs were too extensive to include directly in a prompt. Instead, they were better suited for retrieval or tool calls that could surface the most relevant examples based on the user’s request.</p>

<p>Highly visual concepts presented another challenge. Curated reference images of approved laces, materials, or stitching patterns often produced more reliable results than text descriptions alone. In those cases, the workflow was designed to provide both text and images to the model.</p>

<p>As we gathered more domain knowledge, we were continually asking the same question:</p>

<blockquote>
  <p>What’s the best way for the application to use this information?</p>
</blockquote>

<p>That question shaped nearly every architecture design decision we made.</p>

<h2 id="selecting-an-image-generation-model">Selecting an Image Generation Model</h2>

<p>As we designed the system, we quickly realized that many of our architectural decisions depended on the capabilities of the image generation model itself. Choosing a model wasn’t simply a matter of finding the one that produced the nicest images. We needed to understand what each model could do, how those capabilities fit into our workflow, and whether the tradeoffs made sense for the application we were building.</p>

<p>One example came from a project where we wanted to generate four different design concepts for the user on the very first iteration. This immediately raised the question of whether the model could generate multiple images in a single request, and whether doing so together would be more efficient than issuing four separate requests, given the tradeoffs each approach carried for latency and cost.</p>

<p>Cost became an important consideration because image generation pricing varies significantly between models. A workflow that generates four images for every user interaction can become expensive very quickly, making it important to balance image quality with the overall cost of running the application. We keep the client updated on these costs and how they could change over time as we are going through the project.</p>

<p>Input capabilities also played a major role in our thought process. Some models only accept text, while others can work with one or more reference images. As we discussed in the previous section, we found that certain types of domain knowledge, such as approved materials, stitching patterns, or historical product designs, were often communicated more effectively through images than text alone. That meant we needed a model capable of accepting image inputs as part of the generation process.</p>

<p>Editing capabilities were another important factor. In many design applications, the first image is only the beginning of the conversation. Users often want to refine an existing design by changing the color, replacing a material, or adjusting part of the product while leaving everything else unchanged. If the workflow depends on iterating from previous designs, the model needs to support image editing in addition to generating images from scratch.</p>

<p>Rather than selecting a model based on benchmarks alone, we found it more useful to evaluate models against realistic product design workflows. Image quality was only one consideration. We also compared cost, latency, support for reference images, editing capabilities, and how well each model handled the kinds of inputs our application would generate. The architecture, the available domain knowledge, and the desired user experience all influenced which model was ultimately the best fit.</p>

<h2 id="building-and-refining-the-system">Building and Refining the System</h2>

<p>Designing the architecture and selecting an image generation model wasn’t the end of the process. Building AI-powered applications is inherently iterative, and many of our early assumptions changed as we gained a better understanding of the domain and observed how the system behaved in practice.</p>

<p>As we experimented with different approaches, we continually refined how information was presented to the models. Small changes to the instructions sent with each request, retrieved context, reference images, or tool outputs could have a significant impact on the quality of the generated designs. Rather than thinking about prompt engineering as a single activity, we found it more useful to think about how the entire system prepared information for the model.</p>

<p>Tracing played an important role throughout this process. Because image generation applications involve many moving parts, including prompts, retrieved knowledge, tool calls, reference images, and model responses, it can be difficult to understand why a particular result was successful or where something went wrong. Building tracing into the application from the beginning gave us visibility into every step of the workflow and made it much easier to evaluate architectural changes as the system evolved.</p>

<p>Evaluation became equally important. Instead of only evaluating the final image, we looked at the entire workflow: whether the correct reference images were retrieved, whether business rules were applied correctly, and whether the generated design reflected the user’s original request. Looking at the complete system helped us identify opportunities for improvement that would have been difficult to find by evaluating the model alone.</p>

<h2 id="putting-it-all-together">Putting It All Together</h2>

<p>Let’s return to our SneakerCompany example.</p>

<p>A customer enters the request:</p>

<p>“A modern sneaker with a premium feel, neutral tones, and subtle texture.”</p>

<p>Rather than sending that request directly to the image generation model, the application begins gathering the additional information needed to produce a useful result.</p>

<p>It applies company-wide business rules, retrieves similar historical sneaker designs, selects curated reference images for approved materials and laces, and incorporates any other relevant context gathered during the design process. The application then combines all of this information into the final request that is sent to the image generation model.</p>

<p>The model uses that enriched context to generate design concepts that better reflect the company’s products, design language, and manufacturing constraints rather than relying solely on the user’s original request and a model’s breadth of knowledge.</p>

<p>Although every application will differ, the overall workflow often follows a similar pattern.</p>

<p><img src="/blog/assets/images/diagram-prompt-to-product.svg" alt="Diagram of user input to product" /></p>

<p>The exact implementation will vary depending on the product and the domain, but the underlying principle remains the same. The application is responsible for transforming a simple user request into the information the image generation model needs to produce useful, realistic, and brand-appropriate designs.</p>

<h2 id="conclusion">Conclusion</h2>

<p>One of the most interesting parts of working with generative AI for physical products is realizing that success rarely comes from the model alone. The real value comes from understanding the people already doing the work, capturing the knowledge they rely on every day, and building systems that translate that expertise into something scalable and usable.</p>

<p>As image generation models continue to evolve, the biggest opportunities won’t simply come from generating better images. They’ll come from designing better systems around those models, systems that combine domain expertise, business rules, reference material, and user requests into a workflow capable of producing reliable and useful results.</p>

<p>Have an idea for how AI can enhance your product? Our team at <a href="https://www.ombulabs.ai/our-services">OmbuLabs.ai</a> can help. <a href="https://www.ombulabs.ai/contact">Let’s chat</a>.</p>]]></content><author><name>fionadl</name></author><category term="artificial-intelligence" /><summary type="html"><![CDATA[AI product design works best when domain expertise, manufacturing constraints, and structured design systems guide image generation toward realistic, brand-aligned products.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.ombulabs.ai/blog/turning-user-ideas-into-ai-generated-designs.png" /><media:content medium="image" url="https://www.ombulabs.ai/blog/turning-user-ideas-into-ai-generated-designs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>