<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Posts on Jafforge Blog</title><link>https://jafforge.com/posts/</link><description>Recent content in Posts on Jafforge Blog</description><generator>Hugo -- 0.146.0</generator><language>en-us</language><lastBuildDate>Tue, 14 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jafforge.com/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>Why I Built a Self-Hosted Analytics Control Center for My Websites</title><link>https://jafforge.com/posts/self-hosted-site-analytics-control-center/</link><pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/self-hosted-site-analytics-control-center/</guid><description>I was tired of checking GA4, Search Console, and Bing in separate tabs for every site. Here is why I built one private control center for the whole portfolio.</description><content:encoded><![CDATA[<p>Running a small portfolio of websites creates a deceptively repetitive analytics
workflow. I would open Google Analytics to check traffic, move to Search Console
for impressions and queries, then open Bing Webmaster Tools for another view of
search performance. Then I would repeat that process for the next site.</p>
<p>None of those products are bad. The problem was the space between them. It took too
much tab switching to build a useful picture of what was happening, and that made
regular reviews slower than they needed to be.</p>
<p>So I built <strong>Site Analytics Tool</strong>, a self-hosted control center that brings Google
Analytics 4, Google Search Console, and Bing Webmaster Tools into one private
portfolio view.</p>
<p><a href="/projects/site-analytics-tool/">Explore the project</a> · <a href="https://admin-a0k.pages.dev/demo">View the public demo</a> · <a href="https://github.com/jafforgehq/site-analytics-tool">Browse the source</a></p>
<h2 id="one-review-too-many-tabs">One review, too many tabs</h2>
<p>The old workflow was fragmented by design:</p>
<ul>
<li>Google Analytics is useful for traffic and engagement, but a review is focused
on one property at a time.</li>
<li>Search Console has its own view of queries, clicks, impressions, and pages.</li>
<li>Bing Webmaster Tools is another separate place to check visibility and site
performance.</li>
</ul>
<p>That becomes a problem when the question is not simply “How did one site do?” but
“Which site needs attention, and why?” A traffic change might be a search issue, a
content issue, a stale data feed, or nothing important at all. Seeing the signals
in isolation makes that judgment slower.</p>
<p>I wanted a single place to start the review, identify the outliers, and then drill
into the site or source that needed attention.</p>
<h2 id="a-portfolio-view-first">A portfolio view first</h2>
<p>The home screen is designed around the decisions I make during a regular review:
what moved, what needs attention, and whether the data is current.</p>
<p><img alt="Portfolio overview with traffic, search performance, action items, top movers, and data coverage" loading="lazy" src="/posts/self-hosted-site-analytics-control-center/overview.png"></p>
<p>It combines portfolio-level traffic and search KPIs with top movers, anomaly
signals, action items, and data-coverage warnings. The goal is not to recreate
every report from the source products. It is to surface the useful signals first,
then make it easy to investigate.</p>
<h2 id="built-for-the-way-i-actually-work">Built for the way I actually work</h2>
<p>This is intentionally not a multi-tenant SaaS product. It is a self-hosted tool
for a solo publisher or a small portfolio operator.</p>
<p>There is no public signup, billing flow, organization workspace, or role-management
screen. That keeps the product focused on the practical job: connecting the sites
I own, reviewing their performance, and deciding what to work on next.</p>
<p>Each site has its own integration status, so it is clear whether Google Analytics,
Search Console, and Bing are connected and healthy.</p>
<p><img alt="Managed sites view showing integration health for Google Search Console, GA4, and Bing" loading="lazy" src="/posts/self-hosted-site-analytics-control-center/sites.png"></p>
<p>The site view also makes it possible to inspect performance at the source level,
including Search Console queries and pages, instead of relying only on a portfolio
summary.</p>
<h2 id="reliable-data-matters-as-much-as-charts">Reliable data matters as much as charts</h2>
<p>A dashboard is only useful when you can trust that its data is current. The tool
supports scheduled daily syncs and manual per-site syncs, then keeps a visible
history of what happened.</p>
<p><img alt="Sync history showing source, trigger, status, duration, row counts, and error information" loading="lazy" src="/posts/self-hosted-site-analytics-control-center/sync-history.png"></p>
<p>I added stale-data detection, integration health, sanitized error messages, and
data-coverage indicators because an empty chart can mean many things. It might be
a genuine traffic drop, a missing integration, a failed sync, or a source that has
not reported yet. Those are very different problems, and the interface should make
that distinction visible.</p>
<h2 id="private-by-design">Private by design</h2>
<p>The dashboard contains real performance data, so security and privacy are part of
the product rather than an afterthought. It uses an allowlisted admin account and
requires TOTP multi-factor authentication before dashboard data can be read.</p>
<p>There is also a privacy mode for screen sharing. It masks site names, domains,
account details, and timestamps, which makes it possible to discuss the product or
share screenshots without exposing the portfolio behind it.</p>
<p>The <a href="https://admin-a0k.pages.dev/demo">public demo</a> uses synthetic data only. It
shows the product experience without reading from the private production setup.</p>
<h2 id="from-dashboard-to-report">From dashboard to report</h2>
<p>Some reviews need to leave the browser. Site Analytics Tool can export data as CSV
or ZIP files and generate a PDF performance report for a selected site and period.</p>
<p><img alt="Ninety-day PDF performance report with key metrics and trends" loading="lazy" src="/posts/self-hosted-site-analytics-control-center/pdf-report-90-days.png"></p>
<p>That makes it easier to keep a record of progress, review results later, or share
a focused update without manually assembling screenshots from several dashboards.</p>
<h2 id="how-it-is-put-together">How it is put together</h2>
<p>The frontend is built with React and TypeScript, hosted as a static application.
Supabase provides authentication, Postgres, Row Level Security, Edge Functions, and
scheduled jobs. The sync functions pull read-only data from Google Analytics 4,
Google Search Console, and Bing Webmaster Tools.</p>
<p>That setup keeps the browser focused on presenting the data while credentials and
provider calls stay on the server side. It also makes the tool portable: it can run
in a project I control instead of depending on a new hosted analytics service.</p>
<h2 id="what-i-am-learning-from-using-it">What I am learning from using it</h2>
<p>The main lesson so far is that analytics becomes more useful when it starts with a
decision, not a dashboard. I do not need more charts for their own sake. I need a
fast way to answer which site changed, what may have caused it, and what deserves
attention next.</p>
<p>Site Analytics Tool is still evolving through day-to-day use, but it already turns
a scattered review process into one focused workspace. If you want to see it in
action, try the public demo or explore the repository.</p>
<p><a href="https://admin-a0k.pages.dev/demo">View the public demo</a> · <a href="/projects/site-analytics-tool/">Explore the project</a> · <a href="https://github.com/jafforgehq/site-analytics-tool">Browse the source</a></p>
]]></content:encoded></item><item><title>From Codebase to Clarity: Building an Onboarding Guide with AI in One Afternoon</title><link>https://jafforge.com/posts/ai-codebase-onboarding/</link><pubDate>Sun, 12 Apr 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/ai-codebase-onboarding/</guid><description>How I used Claude Code to analyze the Grafana repository and generate a complete onboarding page with architecture diagrams, role-based views, and interactive navigation.</description><content:encoded><![CDATA[<p><em>This is the first post in a series where I document real, practical AI agent use cases. Not hype, not theory. Just things I actually built that saved me real time. If you are curious about what AI coding tools can do beyond autocomplete, this series is for you.</em></p>
<p><strong><a href="/demos/grafana-onboarding.html">See the live onboarding page here</a></strong></p>
<h2 id="the-problem">The Problem</h2>
<p>You join a new project. The repo has millions of lines of code, hundreds of services, and a directory structure that looks like it evolved over a decade. Someone points you at a README that was last updated six months ago and says &ldquo;just read the code.&rdquo;</p>
<p>We have all been there. And it is a terrible experience.</p>
<p>I wanted to see if AI could solve this. Not by explaining one file at a time, but by analyzing an entire repository and producing something a new engineer could actually use on day one.</p>
<h2 id="the-idea">The Idea</h2>
<p>Take a large, real-world open source project. Point an AI coding tool at it. Ask it to produce a complete onboarding guide, with architecture diagrams, folder explanations, common recipes, and getting started steps.</p>
<p>I picked Grafana because it is genuinely complex. Go backend, React/TypeScript frontend, plugin system, multiple database backends, gRPC communication, feature flags, CUE schemas, Yarn workspaces. If the approach works here, it works anywhere.</p>
<p>The tool I used was Claude Code, but the same concept applies to ChatGPT, Cursor, Copilot, or any AI tool that can read a codebase.</p>
<h2 id="what-i-asked-for">What I Asked For</h2>
<p>The prompt was straightforward. I asked Claude Code to:</p>
<ol>
<li>Analyze the repository structure, entry points, and architecture</li>
<li>Generate Mermaid diagrams for the system architecture and data flow</li>
<li>Produce a single self-contained HTML page with everything a new engineer needs</li>
</ol>
<p>The key was being specific about the output structure. I asked for a hero section, an overview, architecture diagrams, component explanations, key folders, important files, getting started steps, and known gotchas.</p>
<h2 id="what-it-produced">What It Produced</h2>
<p>The first pass gave me a solid foundation. A clean dark-themed HTML page with:</p>
<ul>
<li>A high-level architecture diagram showing how the browser, backend, database, and plugins connect</li>
<li>A data flow sequence diagram tracing a request from the browser through middleware, API handlers, and service layer to external data sources</li>
<li>Cards explaining each major component (HTTP API, service layer, plugin system, frontend SPA)</li>
<li>A table of key directories with what lives in each one</li>
<li>A list of the 10 most important files and what they do</li>
<li>Step-by-step getting started instructions</li>
<li>A command reference block with the most common dev commands</li>
</ul>
<p>All generated from actually reading the codebase. Not hallucinated, not generic. It found the real entry points (<code>pkg/cmd/grafana/main.go</code>, <code>public/app/index.ts</code>), the real dependency injection setup (<code>pkg/server/wire.go</code>), and the real route definitions.</p>
<p><img alt="Architecture diagram generated from the Grafana codebase" loading="lazy" src="/posts/ai-codebase-onboarding/architecture_diagram.png"></p>
<p><img alt="Data flow diagram showing how a request travels through the system" loading="lazy" src="/posts/ai-codebase-onboarding/data_flow.png"></p>
<h2 id="making-it-better">Making It Better</h2>
<p>The first version was good but targeted at experienced engineers. I pushed it further by asking &ldquo;what would make this useful for someone less experienced?&rdquo;</p>
<p>That added:</p>
<p><strong>Concept explainers.</strong> Plain-English descriptions of things like dependency injection, middleware, ORMs, gRPC, and Redux. Each one uses analogies instead of jargon. Middleware becomes &ldquo;security checkpoints at an airport.&rdquo; Dependency injection becomes &ldquo;instead of building your own database connection, someone hands you one.&rdquo;</p>
<p><img alt="Key concepts explained with beginner-friendly cards" loading="lazy" src="/posts/ai-codebase-onboarding/key_concepts.png"></p>
<p><strong>A mental model section.</strong> The most important paragraph on the whole page: &ldquo;This repo has millions of lines of code. That is OK. You are not expected to understand all of it.&rdquo; Then a simple apartment building analogy. Frontend is the apartment. Backend is the plumbing. Plugins are appliances.</p>
<p><strong>A &ldquo;Where Do I Look?&rdquo; decision tree.</strong> Ten common tasks (&ldquo;I want to change what a page looks like&rdquo;, &ldquo;I want to add an API endpoint&rdquo;) mapped directly to the files you need to touch.</p>
<p><strong>Common recipes.</strong> Step-by-step instructions for adding a backend endpoint, adding a frontend page, adding a feature toggle, and adding a database migration.</p>
<p><strong>A debugging guide.</strong> A table mapping symptoms (backend won&rsquo;t compile, API returns 500, blank page) to where to look and what to try.</p>
<h2 id="role-based-views">Role-Based Views</h2>
<p>Then I had another idea. Different roles care about different things. A software engineer needs to know how to build and debug. An engineering manager needs to understand team boundaries and PR patterns. A product manager needs to understand what features exist and how they ship.</p>
<p>So I added a role picker at the top of the page. Four tabs: Everyone, Software Engineer, Engineering Manager, Product Manager. Clicking one filters the entire page to show only relevant sections.</p>
<p>The implementation is pure CSS. Each section has a <code>data-roles</code> attribute, and the body class toggles visibility. No framework needed.</p>
<p>For EMs, it shows a service ownership map, what to look for in PRs (scope creep, migration risk, Wire changes), and deployment model notes.</p>
<p>For PMs, it shows core product areas (dashboards, explore, alerting), how features ship through feature flags, and where to find product context like routes, config, and user docs.</p>
<p>Each role also gets its own first-week checklist with practical tasks.</p>
<h2 id="the-finishing-touches">The Finishing Touches</h2>
<p>A few more iterations added:</p>
<ul>
<li><strong>Grouped dropdown navigation.</strong> The sticky nav went from 15+ flat links to 4 clean groups: Architecture, Understand, Build, Reference. Each expands on hover.</li>
<li><strong>Copy buttons on code blocks.</strong> One click to copy any command. Shows &ldquo;Copied!&rdquo; feedback.</li>
<li><strong>Back-to-top button.</strong> The page is long. A floating arrow appears after scrolling.</li>
<li><strong>Reading time badges.</strong> Each section header shows an estimated reading time calculated from word count.</li>
<li><strong>FAQ accordion.</strong> Nine questions every new hire asks: &ldquo;Do I need Docker?&rdquo;, &ldquo;How do I reset my local DB?&rdquo;, &ldquo;How do I enable a feature flag?&rdquo; Each expands to show the answer.</li>
</ul>
<p>All of this is a single HTML file. No build step, no dependencies, no npm install. Open it in a browser and it works.</p>
<h2 id="what-the-ai-got-right">What the AI Got Right</h2>
<p>The architecture analysis was surprisingly accurate. It correctly identified:</p>
<ul>
<li>The modular monolith pattern with services in <code>pkg/services/</code> wired via Google Wire</li>
<li>The newer App SDK pattern in <code>apps/</code> using Kubernetes-style APIs</li>
<li>Two query paths: built-in data sources via <code>pkg/tsdb/</code> and external plugins via gRPC</li>
<li>The frontend boot sequence from <code>public/app/index.ts</code> through <code>initApp.ts</code> to <code>app.ts</code></li>
<li>Feature toggle management in <code>pkg/services/featuremgmt/</code></li>
<li>The fact that frontend and backend deploy independently</li>
</ul>
<p>It also correctly identified that <code>yarn test</code> runs in watch mode by default (a gotcha that trips up every new hire) and that the first build takes about 3 minutes.</p>
<h2 id="what-the-ai-might-miss">What the AI Might Miss</h2>
<p>This is important to be honest about.</p>
<p><strong>Tribal knowledge.</strong> The AI can read code but it cannot capture unwritten team conventions, &ldquo;why we did it this way&rdquo; context, or which parts of the codebase are actively being rewritten.</p>
<p><strong>Ownership accuracy.</strong> The team-to-directory mapping is inferred from code structure. Real ownership might differ.</p>
<p><strong>Staleness.</strong> The codebase changes daily. File paths and patterns described in the guide might be outdated by the time someone reads it.</p>
<p><strong>Oversimplification.</strong> Any diagram that fits on a screen is leaving things out. Service boundaries and internal APIs are more complex than what is shown.</p>
<p>I added an &ldquo;AI Limitations&rdquo; section to the page itself so readers know this upfront.</p>
<h2 id="how-to-do-this-for-your-project">How to Do This for Your Project</h2>
<p>The approach is not Grafana-specific. Here is how to replicate it:</p>
<p><strong>1. Pick your tool.</strong> Claude Code, ChatGPT with file upload, Cursor, GitHub Copilot in the IDE. Anything that can read multiple files.</p>
<p><strong>2. Give it context.</strong> Point it at the root of your repo. If using Claude Code, it reads the filesystem directly. If using ChatGPT, upload key files (README, main entry point, config, route definitions).</p>
<p><strong>3. Be specific about output.</strong> Do not just say &ldquo;explain this codebase.&rdquo; Ask for a structured onboarding page with specific sections: architecture diagram, key folders, getting started steps, common recipes.</p>
<p><strong>4. Iterate.</strong> The first pass will be decent but generic. Push it: &ldquo;What would help a junior engineer?&rdquo; &ldquo;Add a debugging guide.&rdquo; &ldquo;Add role-based views.&rdquo; Each round makes it significantly better.</p>
<p><strong>5. Add honest limitations.</strong> Tell the AI to include a section about what it might have gotten wrong. This builds trust with readers.</p>
<p><strong>6. Keep it maintainable.</strong> A single HTML file is easy to regenerate. When the codebase changes significantly, run the process again. It takes minutes, not days.</p>
<h2 id="the-bigger-picture">The Bigger Picture</h2>
<p>The interesting thing here is not the specific output. It is the pattern.</p>
<p>Every company has onboarding docs that are either nonexistent, outdated, or written for someone who already understands the system. AI tools can now read an entire codebase and produce something useful in minutes.</p>
<p>This does not replace a good mentor or a well-maintained wiki. But it fills the gap between &ldquo;here is the repo, good luck&rdquo; and &ldquo;here is a curated 20-page guide that took someone a week to write.&rdquo;</p>
<p>The onboarding page I built for Grafana has architecture diagrams, concept explainers, role-based filtering, interactive FAQ, debugging guides, and step-by-step recipes. It took an afternoon of iterating with Claude Code. Doing this manually would have taken a week of reading code and writing docs.</p>
<p>That is the real value. Not replacing human knowledge, but generating a solid first draft that gets a new team member from zero to oriented in 30 minutes instead of 3 days.</p>
<h2 id="try-it-yourself">Try It Yourself</h2>
<p>If you want to try this on your own project, start with something like:</p>
<blockquote>
<p>&ldquo;You are a senior software architect creating an onboarding guide for a new engineer. Analyze this repository and generate a complete onboarding page with architecture diagrams, key folders, important files, getting started steps, and common gotchas.&rdquo;</p></blockquote>
<p>Then iterate from there. You will be surprised how good the first pass is, and how much better it gets with a few rounds of feedback.</p>
]]></content:encoded></item><item><title>I Built a 3-Agent Workflow That Ships Dev Tools Daily</title><link>https://jafforge.com/posts/utility-forge-3-agent-workflow/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/utility-forge-3-agent-workflow/</guid><description>How Utility Forge uses Product Owner, Software Engineer, and QA agents coordinating through GitHub Issues and Actions to ship developer tools end-to-end, every day.</description><content:encoded><![CDATA[<p>I&rsquo;ve been exploring multi-agent systems and wanted to test a specific idea: can three AI agents with fixed roles actually ship software to production on their own, daily? So I built <strong>Utility Forge</strong> to find out.</p>
<p><a href="https://jafforgehq.github.io/utility-forge/">Live demo</a> · <a href="https://github.com/jafforgehq/utility-forge">Repo</a></p>
<h2 id="the-three-agents">The Three Agents</h2>
<ul>
<li><strong>Ava PO</strong> (Product Owner): generates one tool idea per day, scores candidates, creates a GitHub issue with acceptance criteria</li>
<li><strong>Eve SE</strong> (Software Engineer): picks up the issue, implements the tool, runs tests, opens a PR</li>
<li><strong>Nora QA</strong> (QA Reviewer): validates the PR against acceptance criteria, auto-merges on pass</li>
</ul>
<p>There&rsquo;s no shared runtime or message queue. They talk through GitHub: issues, labels, comments, <code>repository_dispatch</code> events. All state lives in the repo, so you can look at any issue and see exactly what happened.</p>
<h2 id="how-it-flows">How It Flows</h2>
<p>Ava fires at 9 AM UTC. She generates three tool candidates via OpenAI, scores each one based on value, effort, confidence, and a novelty penalty that discourages repeating recent ideas. The winner becomes a GitHub issue. Ava dispatches <code>se_ready</code>, Eve picks it up, generates the tool under <code>site/tools/</code>, runs tests, and opens a PR. Eve then dispatches <code>se_pr_ready</code>. Nora waits 15 minutes before starting (without that delay she&rsquo;d sometimes evaluate a PR before GitHub had finished processing it), then runs the test suite, checks the acceptance criteria from the original issue, and auto-merges if everything passes. The merge triggers the Pages deploy.</p>
<p>There&rsquo;s also a watchdog that runs hourly and re-dispatches any agent that&rsquo;s been sitting idle too long. It&rsquo;s what keeps the pipeline from silently stalling after a transient failure.</p>
<h2 id="whats-shipped-so-far">What&rsquo;s Shipped So Far</h2>
<ul>
<li>JSON Formatter / Minifier / Key Sorter</li>
<li>Markdown Table Builder from CSV</li>
<li>SQL Formatter and Pretty Printer</li>
<li>Cron Expression Explainer</li>
</ul>
<p>Small, focused, no-install tools. Exactly what the scoring formula favors.</p>
<h2 id="a-few-things-i-learned">A Few Things I Learned</h2>
<p>I didn&rsquo;t need a message queue or a custom orchestration framework. GitHub Issues + labels + dispatch events turned out to be enough to coordinate three independent agents. That surprised me.</p>
<p>The part I underestimated was how much the PO prompt matters. Ava&rsquo;s acceptance criteria get re-read by Nora later to drive QA decisions. When Ava writes something vague, Nora makes vague decisions. Getting that first prompt right had more impact than anything I did in the SE or QA workflows.</p>
<p>I also added fallbacks everywhere. Ava falls back to a seed idea file when OpenAI fails, Nora retries failed merges with exponential backoff. An autonomous system that hard-crashes on any API hiccup isn&rsquo;t really autonomous.</p>
<p>Still alpha, but it ships something every day. That was the goal.</p>
]]></content:encoded></item><item><title>Google Analytics MCP: GA4 Data in Natural Language</title><link>https://jafforge.com/posts/google-analytics-mcp/</link><pubDate>Sat, 21 Feb 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/google-analytics-mcp/</guid><description>A practical look at my MCP server that combines Google Search Console and Google Analytics, letting you ask SEO and traffic questions in plain English.</description><content:encoded><![CDATA[<p>I just published a new project called <a href="https://github.com/jafforgehq/google-analytics-mcp"><strong>google-analytics-mcp</strong></a>, and I wanted to share why I&rsquo;m excited about it.</p>
<p>Even though the repo name says &ldquo;analytics,&rdquo; the project includes both:</p>
<ul>
<li><strong>Google Search Console data</strong> for search performance</li>
<li><strong>Google Analytics data</strong> for traffic and behavior</li>
</ul>
<p>The goal is simple: connect these sources to MCP-compatible AI tools so you can ask questions in plain language instead of jumping through five dashboards every time.</p>
<h2 id="why-i-built-it">Why I Built It</h2>
<p>I kept running into the same problem: the data existed, but getting quick answers still took too many steps.</p>
<p>Usually the flow looked like this:</p>
<ul>
<li>Open Search Console for query and ranking data</li>
<li>Open GA for sessions, channels, and engagement</li>
<li>Export or copy numbers</li>
<li>Try to connect everything manually</li>
</ul>
<p>That works, but it is slow when you just need a fast decision. MCP makes it easier to query both systems in one place and keep momentum.</p>
<h2 id="what-it-shows-in-practice">What It Shows in Practice</h2>
<p>Here is the dashboard-style overview from the project.</p>
<p><img alt="SEO Analytics dashboard overview" loading="lazy" src="/posts/google-analytics-mcp/seo-dashboard-overview.png"></p>
<p>You get high-level visibility first: impressions, clicks, CTR, and average position, followed by top pages and performance trends. It gives you a solid snapshot before you drill deeper.</p>
<h2 id="google-search-console--google-analytics-together">Google Search Console + Google Analytics Together</h2>
<p>This is the part that matters most to me.</p>
<p><strong>From Search Console</strong>, you can quickly inspect:</p>
<ul>
<li>Top queries by clicks and impressions</li>
<li>CTR by query</li>
<li>Average position trends</li>
</ul>
<p><strong>From Google Analytics</strong>, you can inspect:</p>
<ul>
<li>Channel mix (organic, direct, referral, social)</li>
<li>Top pages by sessions</li>
<li>Engagement metrics like average session duration</li>
</ul>
<p>When both are available through the same MCP flow, it becomes much easier to connect ranking changes with traffic and engagement outcomes.</p>
<h2 id="detailed-views">Detailed Views</h2>
<p>Example of a query-level Search Console breakdown:</p>
<p><img alt="Top search queries with clicks, impressions, CTR, and position" loading="lazy" src="/posts/google-analytics-mcp/search-console-top-queries.png"></p>
<p>Example of analytics views mixed with SEO context:</p>
<p><img alt="Traffic channels, CTR vs position, top pages, and session duration" loading="lazy" src="/posts/google-analytics-mcp/analytics-channel-and-pages.png"></p>
<p>This is exactly the type of view I wanted: one place to inspect visibility, clicks, channel distribution, and page behavior without switching tools every minute.</p>
<h2 id="where-this-helps-most">Where This Helps Most</h2>
<p>I see this being useful in three common scenarios:</p>
<ul>
<li><strong>Weekly SEO reviews</strong>: get quick summaries of what moved up or down</li>
<li><strong>Content prioritization</strong>: find pages with high impressions but low CTR</li>
<li><strong>Growth experiments</strong>: compare ranking wins with actual traffic quality</li>
</ul>
<p>It does not replace deep analysis in GA or Search Console UI, but it is very good for fast exploration and decision-making.</p>
<h2 id="repo">Repo</h2>
<p>If you want to test it, contribute, or adapt it for your own setup, the repository is here:</p>
<p><a href="https://github.com/jafforgehq/google-analytics-mcp">https://github.com/jafforgehq/google-analytics-mcp</a></p>
<p>I&rsquo;ll keep improving it and share follow-up updates once I add more capabilities.</p>
]]></content:encoded></item><item><title>Building SplitDecision: Multi-Agent AI Debates in Next.js</title><link>https://jafforge.com/posts/splitdecision-multi-agent-debate/</link><pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/splitdecision-multi-agent-debate/</guid><description>How I built a web app where four AI agents with distinct personalities debate your decisions in real-time — covering streaming, prompting, and verdict parsing.</description><content:encoded><![CDATA[<p>Ever been stuck choosing between two options? What if you could watch four AI agents with wildly different personalities argue it out for you, live?</p>
<p>That&rsquo;s exactly what I built with <a href="https://splitdecision.vercel.app/"><strong>SplitDecision</strong></a>. You type in two options, hit compare, and a full-blown debate unfolds in real-time, token by token, complete with rebuttals, a final verdict, and a confidence score.</p>
<p>Here&rsquo;s what I learned and how it all works under the hood.</p>
<h2 id="what-is-splitdecision">What Is SplitDecision?</h2>
<p>You enter two options you&rsquo;re torn between. Four AI agents debate them in two rounds, then a synthesizer agent delivers a final verdict with a confidence score. The whole thing streams live.</p>
<p><img alt="SplitDecision input form" loading="lazy" src="/posts/splitdecision-multi-agent-debate/input_ui.png"></p>
<p><strong>The stack:</strong></p>
<ul>
<li><strong>Frontend</strong>: Next.js 15 (App Router), React 19, TypeScript</li>
<li><strong>Styling &amp; Animations</strong>: Tailwind CSS, Framer Motion</li>
<li><strong>AI</strong>: OpenAI API (GPT-4o Mini / GPT-4.1 Nano / GPT-4.1 Mini)</li>
<li><strong>Rate Limiting &amp; Storage</strong>: Upstash Redis</li>
<li><strong>Deployment</strong>: Vercel</li>
</ul>
<h2 id="the-four-agents">The Four Agents</h2>
<p>Each agent has a fixed archetype that shapes how they approach any comparison:</p>
<ul>
<li><strong>The Analyst</strong> (Blue) - Data-driven. Cites specs, benchmarks, and numbers. Won&rsquo;t give you a vague opinion.</li>
<li><strong>The Contrarian</strong> (Red) - Defends the underdog. Exposes hidden costs and overlooked advantages.</li>
<li><strong>The Pragmatist</strong> (Green) - Your experienced friend who&rsquo;s actually used both options.</li>
<li><strong>The Wildcard</strong> (Purple) - Sees angles nobody else does. Future trends, philosophical implications, second-order effects.</li>
</ul>
<p><img alt="The Analyst agent responding in a Mac vs Windows debate" loading="lazy" src="/posts/splitdecision-multi-agent-debate/agent_response.png"></p>
<p>The interactions between them are where it gets interesting. The Contrarian tears apart The Analyst&rsquo;s data, The Wildcard reframes the entire discussion, and The Pragmatist brings everyone back to earth.</p>
<h2 id="how-the-debate-works">How the Debate Works</h2>
<p>The debate flows through four phases:</p>
<p><strong>1. Validation</strong> - A validation agent checks whether the comparison makes sense. Temperature set to 0.0 for deterministic output, plus OpenAI&rsquo;s moderation API for content safety.</p>
<p><strong>2. Round 1 - Initial Takes</strong> - Each agent responds sequentially with 400 tokens. Temperature at 0.9 for personality-rich responses.</p>
<p><strong>3. Round 2 - Rebuttals</strong> - Each agent receives the full Round 1 transcript and responds directly to the others by name. 250 tokens to keep things punchy.</p>
<p><strong>4. Verdict</strong> - A synthesizer agent reads everything and produces a structured verdict with a winner, confidence score (50-95%), and conditional recommendations.</p>
<p>The orchestration is a simple loop on the client side:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">agentKey</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">AGENT_ORDER</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">msgId</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`r1-</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">agentKey</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">setMessages</span>(<span style="color:#a6e22e">prev</span> <span style="color:#f92672">=&gt;</span> [...<span style="color:#a6e22e">prev</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">id</span>: <span style="color:#66d9ef">msgId</span>, <span style="color:#a6e22e">agentKey</span>, <span style="color:#a6e22e">round</span>: <span style="color:#66d9ef">1</span>, <span style="color:#a6e22e">text</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;&#39;</span>, <span style="color:#a6e22e">isStreaming</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  }]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">fullText</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> <span style="color:#66d9ef">await</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunk</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">streamChat</span>(<span style="color:#a6e22e">apiKey</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;agent&#39;</span>, <span style="color:#a6e22e">agentKey</span>, ...
</span></span><span style="display:flex;"><span>  })) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">fullText</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">chunk</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">setMessages</span>(<span style="color:#a6e22e">prev</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">prev</span>.<span style="color:#a6e22e">map</span>(<span style="color:#a6e22e">msg</span> <span style="color:#f92672">=&gt;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">msg</span>.<span style="color:#a6e22e">id</span> <span style="color:#f92672">===</span> <span style="color:#a6e22e">msgId</span> <span style="color:#f92672">?</span> { ...<span style="color:#a6e22e">msg</span>, <span style="color:#a6e22e">text</span>: <span style="color:#66d9ef">fullText</span> } <span style="color:#f92672">:</span> <span style="color:#a6e22e">msg</span>
</span></span><span style="display:flex;"><span>    ));
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">round1Results</span>[<span style="color:#a6e22e">agentKey</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">fullText</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Each agent streams token by token. When Round 1 finishes, the full transcript gets bundled into Round 2 prompts so agents can reference each other.</p>
<h2 id="prompt-engineering">Prompt Engineering</h2>
<p>This is where the real work happened.</p>
<h3 id="personality-through-system-prompts">Personality Through System Prompts</h3>
<p>Vague instructions like &ldquo;be analytical&rdquo; don&rsquo;t work. You need to tell the model exactly how to think. Here&rsquo;s the Analyst&rsquo;s default system prompt:</p>
<blockquote>
<p><em>You are The Analyst, a data-driven, no-nonsense comparison expert. Focus exclusively on specs, numbers, benchmarks, cost, market data, and measurable differences. Quantify everything you can. Your tone is professional and concise. Never give vague opinions, back claims with data or concrete reasoning. Keep your response under 150 words.</em></p></blockquote>
<p>And the same agent in &ldquo;Startup Bros&rdquo; theme:</p>
<blockquote>
<p><em>You are The Analyst, a growth-obsessed startup metrics guru. You talk in terms of TAM, CAC, LTV, burn rate, and runway. Every comparison is framed as a market opportunity. You reference Y Combinator, a16z, and Series A benchmarks. You say things like &rsquo;the unit economics here are clear.&rsquo; Keep your response under 150 words.</em></p></blockquote>
<p>Same archetype, completely different personality. This is how I support 9 debate themes with 72 unique agent prompts total.</p>
<h3 id="making-ai-sound-human">Making AI Sound Human</h3>
<p>I added global writing rules to every prompt to avoid AI slop:</p>
<ul>
<li>Use contractions (don&rsquo;t, can&rsquo;t, it&rsquo;s)</li>
<li>Vary sentence length</li>
<li>Never use em dashes or semicolons</li>
<li>Avoid hedge words (arguably, essentially, fundamentally)</li>
<li>No filler phrases (at the end of the day, when it comes to)</li>
</ul>
<p>These small constraints made a massive difference. The responses feel like actual personalities instead of four variations of &ldquo;certainly, here&rsquo;s my analysis.&rdquo;</p>
<h3 id="temperature-and-token-budgets">Temperature and Token Budgets</h3>
<table>
  <thead>
      <tr>
          <th>Phase</th>
          <th>Temperature</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Validation</td>
          <td>0.0</td>
          <td>Deterministic yes/no</td>
      </tr>
      <tr>
          <td>Debates (R1 &amp; R2)</td>
          <td>0.9</td>
          <td>Creative, personality-rich</td>
      </tr>
      <tr>
          <td>Verdict</td>
          <td>0.7</td>
          <td>Grounded synthesis</td>
      </tr>
  </tbody>
</table>
<p>Lower temperatures made all four agents sound the same. 0.9 gave each personality room to breathe.</p>
<p>Token limits shape how agents communicate too. Round 1 gets 400 tokens for real arguments, Round 2 gets 250 to force direct engagement instead of restating positions, and the Verdict gets 500 for structured synthesis.</p>
<h2 id="streaming">Streaming</h2>
<p>I built dual-mode streaming: direct browser calls when users bring their own API key, and server-proxied calls for the free tier.</p>
<h3 id="direct-browser-streaming">Direct Browser Streaming</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span><span style="color:#f92672">*</span> <span style="color:#a6e22e">streamDirect</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">apiKey</span>: <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">req</span>: <span style="color:#66d9ef">StreamRequest</span>
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">AsyncGenerator</span>&lt;<span style="color:#f92672">string</span>&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">client</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">OpenAI</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">apiKey</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">dangerouslyAllowBrowser</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">stream</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">chat</span>.<span style="color:#a6e22e">completions</span>.<span style="color:#a6e22e">create</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">model</span>, <span style="color:#a6e22e">messages</span>, <span style="color:#a6e22e">max_tokens</span>, <span style="color:#a6e22e">temperature</span>, <span style="color:#a6e22e">stream</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> <span style="color:#66d9ef">await</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunk</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">stream</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">text</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">choices</span>[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">?</span>.<span style="color:#a6e22e">delta</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">content</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">text</span>) <span style="color:#66d9ef">yield</span> <span style="color:#a6e22e">text</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>AsyncGenerator</code> pattern lets the UI consume tokens with a simple <code>for await</code> loop, keeping orchestration code clean.</p>
<h3 id="server-proxied-streaming">Server-Proxied Streaming</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">POST</span>(<span style="color:#a6e22e">req</span>: <span style="color:#66d9ef">Request</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ip</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">headers</span>.<span style="color:#66d9ef">get</span>(<span style="color:#e6db74">&#39;x-forwarded-for&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">limit</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">rateLimit</span>(<span style="color:#a6e22e">ip</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">limit</span>.<span style="color:#a6e22e">ok</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Response</span>(<span style="color:#e6db74">&#39;Rate limited&#39;</span>, { <span style="color:#a6e22e">status</span>: <span style="color:#66d9ef">429</span> });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">stream</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">chat</span>.<span style="color:#a6e22e">completions</span>.<span style="color:#a6e22e">create</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">model</span>, <span style="color:#a6e22e">messages</span>, <span style="color:#a6e22e">max_tokens</span>, <span style="color:#a6e22e">temperature</span>, <span style="color:#a6e22e">stream</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Response</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ReadableStream</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">async</span> <span style="color:#a6e22e">start</span>(<span style="color:#a6e22e">controller</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">for</span> <span style="color:#66d9ef">await</span> (<span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunk</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">stream</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">text</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">choices</span>[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">?</span>.<span style="color:#a6e22e">delta</span><span style="color:#f92672">?</span>.<span style="color:#a6e22e">content</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">text</span>) <span style="color:#a6e22e">controller</span>.<span style="color:#a6e22e">enqueue</span>(<span style="color:#a6e22e">encoder</span>.<span style="color:#a6e22e">encode</span>(<span style="color:#a6e22e">text</span>));
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">controller</span>.<span style="color:#a6e22e">close</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Same experience for the user, but the API key stays on the server. Rate limiting uses Upstash&rsquo;s fixed-window algorithm, 50 comparisons per IP per 24 hours.</p>
<h2 id="the-verdict-system">The Verdict System</h2>
<p>The verdict isn&rsquo;t just &ldquo;Option A wins.&rdquo; I enforce a structured format in the prompt:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>WINNER: [Option A or Option B]
</span></span><span style="display:flex;"><span>CONFIDENCE: [50-95]%
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>[3-4 sentence synthesis]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>WHAT WOULD FLIP THIS: [1-2 sentences]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>PICK [Option A] IF: [1 sentence]
</span></span><span style="display:flex;"><span>PICK [Option B] IF: [1 sentence]
</span></span></code></pre></div><p>Then parse it with regex as the verdict streams in:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">parseVerdict</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">text</span>: <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">optionA</span>: <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">optionB</span>: <span style="color:#66d9ef">string</span>
</span></span><span style="display:flex;"><span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">winnerMatch</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">text</span>.<span style="color:#a6e22e">match</span>(<span style="color:#e6db74">/WINNER:\s*(.+)/</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">confMatch</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">text</span>.<span style="color:#a6e22e">match</span>(<span style="color:#e6db74">/CONFIDENCE:\s*(\d+)/</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">winner</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">winnerMatch</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">raw</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">winnerMatch</span>[<span style="color:#ae81ff">1</span>].<span style="color:#a6e22e">trim</span>().<span style="color:#a6e22e">toLowerCase</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">raw</span>.<span style="color:#a6e22e">includes</span>(<span style="color:#a6e22e">optionA</span>.<span style="color:#a6e22e">toLowerCase</span>())) <span style="color:#a6e22e">winner</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">optionA</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">raw</span>.<span style="color:#a6e22e">includes</span>(<span style="color:#a6e22e">optionB</span>.<span style="color:#a6e22e">toLowerCase</span>())) <span style="color:#a6e22e">winner</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">optionB</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">confidence</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">confMatch</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">?</span> Math.<span style="color:#a6e22e">max</span>(<span style="color:#ae81ff">50</span>, Math.<span style="color:#a6e22e">min</span>(<span style="color:#ae81ff">95</span>, parseInt(<span style="color:#a6e22e">confMatch</span>[<span style="color:#ae81ff">1</span>], <span style="color:#ae81ff">10</span>)))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">:</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> { <span style="color:#a6e22e">winner</span>, <span style="color:#a6e22e">confidence</span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Confidence is clamped to 50-95%. Below 50% doesn&rsquo;t make sense for a binary choice, and above 95% feels dishonest for subjective comparisons.</p>
<h2 id="the-theming-system">The Theming System</h2>
<p>There are 9 debate themes that completely reshape agent personalities:</p>
<ul>
<li><strong>Default Panel</strong> - Professional experts giving measured takes</li>
<li><strong>Startup Bros</strong> - Everything framed as market opportunity with VC lingo</li>
<li><strong>Academic Panel</strong> - Peer-reviewed discourse with citations</li>
<li><strong>Bar Argument</strong> - Friends arguing over drinks, one of them definitely googled it</li>
<li><strong>Shark Tank</strong> - Is this comparison worth investing in?</li>
<li><strong>Reddit Thread</strong> - Upvotes, hot takes, and &ldquo;this is the way&rdquo;</li>
<li><strong>Courtroom Trial</strong> - Legal drama with expert witnesses and objections</li>
<li><strong>Sports Commentary</strong> - Play-by-play analysis of the matchup</li>
<li><strong>Philosophy Seminar</strong> - Existential deliberation on the nature of choice</li>
</ul>
<p><img alt="All 9 debate themes available in SplitDecision" loading="lazy" src="/posts/splitdecision-multi-agent-debate/theme_selector.png"></p>
<p>Each theme rewrites all four agent prompts for both rounds, 72 total plus validation and verdict prompts. A &ldquo;React vs Svelte&rdquo; debate feels completely different as a Courtroom Trial versus a Bar Argument.</p>
<p><img alt="Trending comparisons feed with winners and confidence scores" loading="lazy" src="/posts/splitdecision-multi-agent-debate/trending_feed.png"></p>
<h2 id="what-i-learned">What I Learned</h2>
<p><strong>Constraints create character.</strong> Without strict writing rules, token limits, and specific vocabulary guidance, all four agents sound the same. The more constraints I added, the more distinct each voice became.</p>
<p><strong>Agent ordering matters.</strong> The first agent sets the frame and everyone else reacts to it. The Analyst always goes first, which means it has outsized influence on every debate.</p>
<p><strong>Structured LLM output is fragile.</strong> LLMs don&rsquo;t always follow format instructions perfectly. Clear regex patterns with sensible fallbacks are essential.</p>
<p><strong>Token budgets shape behavior.</strong> The Round 2 limit of 250 tokens was the breakthrough. It forced agents to actually engage with each other instead of restating their position.</p>
<p><strong>Streaming changes perception.</strong> Watching agents &ldquo;think&rdquo; token by token feels like a live event. It&rsquo;s fundamentally different from waiting for a complete response.</p>
<p><strong>Claude Code made this viable as a solo project.</strong> From streaming logic to 72 unique agent prompts, AI-assisted development made the scope manageable.</p>
<h2 id="wrap-up">Wrap Up</h2>
<p>Multi-agent systems don&rsquo;t need to be complicated infrastructure projects. Good prompt engineering, clear agent archetypes, and a streaming-first architecture can create AI interactions that feel like watching a real discussion.</p>
<p>The biggest takeaway? <strong>Personality in prompts matters more than model selection.</strong> GPT-4o Mini with a great system prompt produces more engaging debates than a larger model with a generic one. The constraints you put on your agents are what give them character.</p>
<p>If you&rsquo;re interested in building multi-agent systems, start with clearly defined roles, invest time in prompt writing, and let your agents actually interact with each other&rsquo;s output. That&rsquo;s where the magic happens.</p>
]]></content:encoded></item><item><title>Running Claude Code with Free Ollama Models</title><link>https://jafforge.com/posts/claude-code-with-ollama/</link><pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/claude-code-with-ollama/</guid><description>How to configure Claude Code&amp;rsquo;s agentic coding workflow to run on free local Ollama models instead of paid API calls — setup, model picks, and tradeoffs.</description><content:encoded><![CDATA[<h2 id="why-this-matters">Why This Matters</h2>
<p>Claude Code is an impressive agentic coding tool that can read, modify, and execute code in your working directory. But there&rsquo;s one catch: it usually requires paying for Claude API calls. What if I told you that you can run the exact same tool with completely free, local models?</p>
<p>That&rsquo;s exactly what I&rsquo;ve been experimenting with lately, and I wanted to share how you can do it too.</p>
<p>What you get with this setup:</p>
<ul>
<li>Cost: Zero API fees. Run as many coding sessions as you want.</li>
<li>Privacy: Your code never leaves your machine. No cloud API calls.</li>
<li>Offline: Work without internet once models are downloaded.</li>
<li>Learning: Compare open-source coding models with Claude&rsquo;s performance.</li>
</ul>
<h2 id="what-is-claude-code">What Is Claude Code?</h2>
<p>Claude Code is Anthropic&rsquo;s official CLI tool that brings AI-powered coding assistance to your terminal. It can understand your codebase, make edits across multiple files, run commands, and help debug issues - all through a conversational interface.</p>
<p>Normally, it connects to Anthropic&rsquo;s API and uses Claude models (Sonnet, Opus, Haiku). But thanks to an Anthropic-compatible API bridge, you can point it at Ollama instead.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before we start, you&rsquo;ll need:</p>
<ol>
<li>
<p><strong>Ollama installed</strong> - If you don&rsquo;t have it yet, check out my earlier post on <a href="https://jafforge.com/posts/running-llm-locally/" title="Ollama guide">running LLMs locally</a>.</p>
</li>
<li>
<p><strong>Claude Code CLI</strong> - Install from npm:</p>
</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>npm install -g @anthropics/claude-code
</span></span></code></pre></div><ol start="3">
<li><strong>Enough RAM</strong> - Claude Code needs models with large context windows (minimum 64k tokens). Plan for at least 8GB of free RAM.</li>
</ol>
<h2 id="step-1-choose-your-model">Step 1: Choose Your Model</h2>
<p>Not all models work well with Claude Code. You need coding-specific models with large context windows. Here are the recommended ones:</p>
<p><strong>Best options:</strong></p>
<ul>
<li><code>qwen2.5-coder:7b</code> - Strong coding performance, good balance</li>
<li><code>qwen2.5-coder:14b</code> - Better quality if you have the RAM</li>
<li><code>deepseek-coder-v2:16b</code> - Excellent at following instructions</li>
<li><code>codellama:13b-instruct</code> - Solid baseline for coding tasks</li>
</ul>
<p>Pull your chosen model:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama pull qwen2.5-coder:7b
</span></span></code></pre></div><p>Tip: The first pull downloads several GBs and can take a few minutes depending on your connection.</p>
<h2 id="step-2-quick-setup-recommended">Step 2: Quick Setup (Recommended)</h2>
<p>The easiest way to get started is using Ollama&rsquo;s automatic configuration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama launch claude
</span></span></code></pre></div><p>This command will:</p>
<ol>
<li>Start the Ollama server if it&rsquo;s not running</li>
<li>Configure the necessary environment variables</li>
<li>Launch Claude Code pointed at your local Ollama instance</li>
</ol>
<p>You&rsquo;ll be prompted to select which model to use from your installed models.</p>
<h2 id="step-3-manual-setup-alternative">Step 3: Manual Setup (Alternative)</h2>
<p>If you prefer more control, you can configure it manually:</p>
<p>Set these environment variables:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export ANTHROPIC_API_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;ollama&#34;</span>
</span></span><span style="display:flex;"><span>export ANTHROPIC_BASE_URL<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http://localhost:11434/v1&#34;</span>
</span></span></code></pre></div><p>Then launch Claude Code with your chosen model:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>claude --model qwen2.5-coder:7b
</span></span></code></pre></div><p>Note: The API key can be any string when using Ollama locally - it just needs to be set.</p>
<h2 id="using-claude-code-with-local-models">Using Claude Code with Local Models</h2>
<p>Once launched, you can use Claude Code exactly as you would with Claude models:</p>
<p>Ask it to create a new feature:</p>



<div class="goat svg-container ">
  
    <svg
      xmlns="http://www.w3.org/2000/svg"
      font-family="Menlo,Lucida Console,monospace"
      
        viewBox="0 0 408 25"
      >
      <g transform='translate(8,16)'>
<text text-anchor='middle' x='0' y='4' fill='currentColor' style='font-size:1em'>C</text>
<text text-anchor='middle' x='8' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='16' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='24' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='32' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='40' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='56' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='72' y='4' fill='currentColor' style='font-size:1em'>R</text>
<text text-anchor='middle' x='80' y='4' fill='currentColor' style='font-size:1em'>E</text>
<text text-anchor='middle' x='88' y='4' fill='currentColor' style='font-size:1em'>S</text>
<text text-anchor='middle' x='96' y='4' fill='currentColor' style='font-size:1em'>T</text>
<text text-anchor='middle' x='112' y='4' fill='currentColor' style='font-size:1em'>A</text>
<text text-anchor='middle' x='120' y='4' fill='currentColor' style='font-size:1em'>P</text>
<text text-anchor='middle' x='128' y='4' fill='currentColor' style='font-size:1em'>I</text>
<text text-anchor='middle' x='144' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='152' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='160' y='4' fill='currentColor' style='font-size:1em'>d</text>
<text text-anchor='middle' x='168' y='4' fill='currentColor' style='font-size:1em'>p</text>
<text text-anchor='middle' x='176' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='184' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='192' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='200' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='216' y='4' fill='currentColor' style='font-size:1em'>f</text>
<text text-anchor='middle' x='224' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='232' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='248' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='256' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='264' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='272' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='288' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='296' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='304' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='312' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='320' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='328' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='336' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='344' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='352' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='360' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='368' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='376' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='384' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='392' y='4' fill='currentColor' style='font-size:1em'>n</text>
</g>

    </svg>
  
</div>
<p>Debug an issue:</p>



<div class="goat svg-container ">
  
    <svg
      xmlns="http://www.w3.org/2000/svg"
      font-family="Menlo,Lucida Console,monospace"
      
        viewBox="0 0 512 25"
      >
      <g transform='translate(8,16)'>
<text text-anchor='middle' x='0' y='4' fill='currentColor' style='font-size:1em'>T</text>
<text text-anchor='middle' x='8' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='16' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='32' y='4' fill='currentColor' style='font-size:1em'>l</text>
<text text-anchor='middle' x='40' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='48' y='4' fill='currentColor' style='font-size:1em'>g</text>
<text text-anchor='middle' x='56' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='64' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='80' y='4' fill='currentColor' style='font-size:1em'>f</text>
<text text-anchor='middle' x='88' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='96' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='104' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='112' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='120' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='128' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='136' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='152' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='160' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='168' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='176' y='4' fill='currentColor' style='font-size:1em'>'</text>
<text text-anchor='middle' x='184' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='200' y='4' fill='currentColor' style='font-size:1em'>w</text>
<text text-anchor='middle' x='208' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='216' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='224' y='4' fill='currentColor' style='font-size:1em'>k</text>
<text text-anchor='middle' x='232' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='240' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='248' y='4' fill='currentColor' style='font-size:1em'>g</text>
<text text-anchor='middle' x='256' y='4' fill='currentColor' style='font-size:1em'>.</text>
<text text-anchor='middle' x='272' y='4' fill='currentColor' style='font-size:1em'>C</text>
<text text-anchor='middle' x='280' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='288' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='304' y='4' fill='currentColor' style='font-size:1em'>y</text>
<text text-anchor='middle' x='312' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='320' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='336' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='344' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='352' y='4' fill='currentColor' style='font-size:1em'>l</text>
<text text-anchor='middle' x='360' y='4' fill='currentColor' style='font-size:1em'>p</text>
<text text-anchor='middle' x='376' y='4' fill='currentColor' style='font-size:1em'>m</text>
<text text-anchor='middle' x='384' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='400' y='4' fill='currentColor' style='font-size:1em'>f</text>
<text text-anchor='middle' x='408' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='416' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='424' y='4' fill='currentColor' style='font-size:1em'>d</text>
<text text-anchor='middle' x='440' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='448' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='456' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='472' y='4' fill='currentColor' style='font-size:1em'>b</text>
<text text-anchor='middle' x='480' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='488' y='4' fill='currentColor' style='font-size:1em'>g</text>
<text text-anchor='middle' x='496' y='4' fill='currentColor' style='font-size:1em'>?</text>
</g>

    </svg>
  
</div>
<p>Refactor code:</p>



<div class="goat svg-container ">
  
    <svg
      xmlns="http://www.w3.org/2000/svg"
      font-family="Menlo,Lucida Console,monospace"
      
        viewBox="0 0 512 25"
      >
      <g transform='translate(8,16)'>
<text text-anchor='middle' x='0' y='4' fill='currentColor' style='font-size:1em'>R</text>
<text text-anchor='middle' x='8' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='16' y='4' fill='currentColor' style='font-size:1em'>f</text>
<text text-anchor='middle' x='24' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='32' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='40' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='48' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='56' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='72' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='80' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='88' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='104' y='4' fill='currentColor' style='font-size:1em'>d</text>
<text text-anchor='middle' x='112' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='120' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='128' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='136' y='4' fill='currentColor' style='font-size:1em'>b</text>
<text text-anchor='middle' x='144' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='152' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='160' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='176' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='184' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='192' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='200' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='208' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='216' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='224' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='232' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='240' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='248' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='264' y='4' fill='currentColor' style='font-size:1em'>l</text>
<text text-anchor='middle' x='272' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='280' y='4' fill='currentColor' style='font-size:1em'>g</text>
<text text-anchor='middle' x='288' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='296' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='312' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='320' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='336' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='344' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='352' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='368' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='384' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='392' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='400' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='408' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='416' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='424' y='4' fill='currentColor' style='font-size:1em'>c</text>
<text text-anchor='middle' x='432' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='440' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='448' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='456' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='472' y='4' fill='currentColor' style='font-size:1em'>p</text>
<text text-anchor='middle' x='480' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='488' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='496' y='4' fill='currentColor' style='font-size:1em'>l</text>
</g>

    </svg>
  
</div>
<p>The model will:</p>
<ul>
<li>Read relevant files in your project</li>
<li>Understand your codebase structure</li>
<li>Make necessary edits</li>
<li>Run commands to test changes</li>
<li>Explain what it did</li>
</ul>
<h2 id="performance-local-vs-cloud">Performance: Local vs Cloud</h2>
<p>Here&rsquo;s what I&rsquo;ve noticed after using both:</p>
<p><strong>What works well locally:</strong></p>
<ul>
<li>Simple bug fixes and code generation</li>
<li>Following clear, specific instructions</li>
<li>Working within a focused codebase area</li>
<li>Standard coding patterns and frameworks</li>
</ul>
<p><strong>Where Claude models still lead:</strong></p>
<ul>
<li>Complex multi-file refactoring</li>
<li>Nuanced architecture decisions</li>
<li>Understanding implicit requirements</li>
<li>Edge case handling</li>
</ul>
<p>For most day-to-day coding tasks, the local models are surprisingly capable. You might be pleasantly surprised.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<p><strong>Out of memory errors?</strong></p>
<ul>
<li>Switch to a smaller model (7b instead of 14b)</li>
<li>Close other applications</li>
<li>Check <code>ollama ps</code> to see memory usage</li>
</ul>
<p><strong>Model responses are slow?</strong></p>
<ul>
<li>Local inference is slower than API calls - that&rsquo;s expected</li>
<li>GPU acceleration helps significantly (Ollama auto-detects)</li>
<li>Smaller models respond faster</li>
</ul>
<p><strong>Context window errors?</strong></p>
<ul>
<li>Some models have smaller context than advertised</li>
<li>Try reducing the number of files you&rsquo;re working with</li>
<li>Use more focused prompts</li>
</ul>
<p><strong>Connection refused?</strong></p>
<ul>
<li>Make sure Ollama server is running: <code>ollama serve</code></li>
<li>Check the base URL is correct: <code>http://localhost:11434/v1</code></li>
</ul>
<h2 id="cost-comparison">Cost Comparison</h2>
<p>Let&rsquo;s do some rough math:</p>
<p><strong>Claude API pricing (Sonnet 4.5):</strong></p>
<ul>
<li>Input: $3 per million tokens</li>
<li>Output: $15 per million tokens</li>
<li>A typical coding session might use 50k-200k tokens</li>
<li>Cost: roughly $0.50-$3 per session</li>
</ul>
<p><strong>Local Ollama setup:</strong></p>
<ul>
<li>One-time: Electricity cost (negligible for CPU, ~$0.10/hour for GPU)</li>
<li>Ongoing: $0</li>
</ul>
<p>If you&rsquo;re doing regular coding sessions, the local setup pays for itself quickly.</p>
<h2 id="when-to-use-each">When to Use Each</h2>
<p><strong>Use local Ollama when:</strong></p>
<ul>
<li>Learning and experimenting</li>
<li>Working on personal projects</li>
<li>You want complete privacy</li>
<li>Budget is a concern</li>
<li>You&rsquo;re offline</li>
</ul>
<p><strong>Use Claude API when:</strong></p>
<ul>
<li>Working on complex production systems</li>
<li>You need the absolute best performance</li>
<li>Speed matters more than cost</li>
<li>You don&rsquo;t want to manage local infrastructure</li>
</ul>
<p>For me, I&rsquo;ve been using local models for 80% of my coding tasks and only switching to Claude API for the really tricky stuff. That hybrid approach gives me the best of both worlds.</p>
<h2 id="advanced-switching-between-models">Advanced: Switching Between Models</h2>
<p>You can easily switch between different local models to compare them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Try a smaller, faster model</span>
</span></span><span style="display:flex;"><span>claude --model qwen2.5-coder:7b
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Or a larger, more capable one</span>
</span></span><span style="display:flex;"><span>claude --model deepseek-coder-v2:16b
</span></span></code></pre></div><p>This is great for finding the right balance between speed and quality for your specific use case.</p>
<h2 id="integration-with-existing-workflow">Integration with Existing Workflow</h2>
<p>Claude Code works in any directory, so you can:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>cd ~/projects/my-app
</span></span><span style="display:flex;"><span>claude --model qwen2.5-coder:7b
</span></span></code></pre></div><p>Then start asking it to help with your project. It will read your code, understand the structure, and make intelligent suggestions.</p>
<p>Works great with:</p>
<ul>
<li>Git repositories</li>
<li>npm/yarn/pnpm projects</li>
<li>Python virtual environments</li>
<li>Any codebase really</li>
</ul>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>Running Claude Code with Ollama is one of those setups that sounds too good to be true but actually works. You get a powerful agentic coding assistant running completely locally and free.</p>
<p>Is it as good as Claude Opus or Sonnet 4.5? Not quite. But for most everyday coding tasks, it&rsquo;s more than capable. And the privacy, cost savings, and learning value make it absolutely worth trying.</p>
<p>If you&rsquo;re already using Ollama for other AI tasks (which I covered in my <a href="https://jafforge.com/posts/running-llm-locally/" title="Running LLM locally">Ollama guide</a>), adding Claude Code is just one more command away.</p>
<p>Give it a try and let me know what you think. Happy coding!</p>
<h2 id="further-reading">Further Reading</h2>
<ul>
<li>Official Ollama Claude Code docs: <a href="https://docs.ollama.com/integrations/claude-code">https://docs.ollama.com/integrations/claude-code</a></li>
<li>Claude Code repository: <a href="https://github.com/anthropics/claude-code">https://github.com/anthropics/claude-code</a></li>
<li>Ollama model library: <a href="https://ollama.com/library">https://ollama.com/library</a></li>
<li>My post on <a href="https://jafforge.com/posts/power-of-mcp-for-ai-tools/" title="MCP guide">MCP integration</a> for even more AI tool capabilities</li>
</ul>
]]></content:encoded></item><item><title>Running AI in Your Pocket: On-Device LLM App with MediaPipe</title><link>https://jafforge.com/posts/pocket-llm-android/</link><pubDate>Mon, 17 Nov 2025 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/pocket-llm-android/</guid><description>How I built PocketLLM — an Android app that runs Google&amp;rsquo;s Gemma 3n model fully offline using MediaPipe Tasks GenAI, with architecture, code, and performance notes.</description><content:encoded><![CDATA[<p>Your phone today is basically a tiny AI workstation. It can run a full
language model <strong>offline</strong>, with <strong>zero cloud</strong>, and <strong>no data leaving
your device</strong>. That was the whole point of PocketLLM, an Android app
I built that runs Google&rsquo;s Gemma 3n model <strong>locally</strong> using MediaPipe
Tasks GenAI.</p>
<p>No servers. No API keys (except initial HuggingFace model download).
No monthly bills. Just pure on-device intelligence.</p>
<h2 id="why-i-wanted-an-on-device-llm">Why I Wanted an On-Device LLM</h2>
<p>I&rsquo;ve been experimenting with running AI locally for a while, first on my MacBook, then on Android. Some reasons why you could want this:</p>
<ul>
<li>Prompts and code stay on your device.</li>
<li>It works offline (flights, remote areas, anywhere).</li>
<li>No rate limits, no API costs, no waiting on network calls.</li>
</ul>
<p>When I learned Gemma 3n could be quantized to ~3GB and run on modern phones, it clicked:</p>
<blockquote>
<p><em>&ldquo;I can ship a full LLM inside an Android app.&rdquo;</em></p></blockquote>
<p>So I did. And it works.</p>
<p><img alt="Showcase" loading="lazy" src="/posts/pocket-llm-android/prompt_showcase.png"></p>
<h2 id="open-llms-make-this-possible">Open LLMs Make This Possible</h2>
<p>Models like:</p>
<ul>
<li>Meta Llama</li>
<li>Google Gemma</li>
<li>Mistral</li>
</ul>
<p>&hellip;gave us something we didn&rsquo;t have before: <strong>public weights</strong> that
anyone can run.</p>
<p>Gemma 3n in particular is wild for its size.
3 billion parameters, quantized to ~3GB, runs smoothly on a flagship
and &ldquo;usable&rdquo; on mid-range devices.</p>
<h2 id="real-world-use-cases">Real-World Use Cases</h2>
<p>Here&rsquo;s where on-device AI shines:</p>
<ul>
<li><strong>Students</strong> → Study help without sending homework to cloud servers</li>
<li><strong>Developers</strong> → Code assistants that keep proprietary code local</li>
<li><strong>Medical professionals</strong> → Draft notes without risking patient
data</li>
<li><strong>Lawyers</strong> → Generate documents without leaking client info</li>
<li><strong>Remote/offline workers</strong> → AI that works on a plane or in a tunnel</li>
</ul>
<p>Basically: if privacy matters, or internet sucks, on-device AI wins.</p>
<h2 id="how-pocketllm-is-built">How PocketLLM Is Built</h2>
<p>PocketLLM uses <strong>MediaPipe Tasks GenAI</strong> to load and run Gemma 3n on
Android. Architecture-wise, it&rsquo;s clean and simple:</p>
<ul>
<li><strong>Domain layer</strong> → business logic</li>
<li><strong>Data layer</strong> → MediaPipe integration + storage</li>
<li><strong>Presentation layer</strong> → Jetpack Compose UI</li>
</ul>
<h2 id="add-mediapipe-to-your-project">Add MediaPipe to Your Project</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span>dependencies {
</span></span><span style="display:flex;"><span>implementation(<span style="color:#e6db74">&#34;com.google.mediapipe:tasks-genai:0.10.27&#34;</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Requirements:</p>
<ul>
<li>Android API 26+</li>
<li>ARM64 device</li>
<li>Enough RAM (4GB+ recommended)</li>
</ul>
<p>For secure key/model storage, you&rsquo;ll want:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span>implementation(<span style="color:#e6db74">&#34;androidx.security:security-crypto:1.1.0-alpha06&#34;</span>)
</span></span></code></pre></div><h2 id="core-mediapipe-integration">Core MediaPipe Integration</h2>
<p>This is where the magic happens, inside <code>ChatRepositoryImpl</code>.</p>
<h3 id="initialize-the-model">Initialize the model</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span>llmInference = <span style="color:#a6e22e">LlmInference</span>.createFromOptions(
</span></span><span style="display:flex;"><span>context,
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">LlmInference</span>.<span style="color:#a6e22e">LlmInferenceOptions</span>.builder()
</span></span><span style="display:flex;"><span>.setModelPath(modelPath)
</span></span><span style="display:flex;"><span>.setMaxTokens(currentSettings.maxTokens)
</span></span><span style="display:flex;"><span>.build()
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><h3 id="generate-responses">Generate responses</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span>session.addQueryChunk(prompt)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">val</span> response = session.generateResponse()
</span></span></code></pre></div><h3 id="customizable-model-behavior">Customizable Model Behavior</h3>
<p>Users can tweak how the model responds through settings:</p>
<ul>
<li><strong>Temperature</strong> (0.0-1.0): Controls randomness.
<ul>
<li>Lower = more focused,</li>
<li>Higher = more creative</li>
</ul>
</li>
<li><strong>Top K &amp; Top P</strong>: Fine-tune token sampling</li>
<li><strong>Max Tokens</strong>: Control response length (128-2048)</li>
</ul>
<p><img alt="Model Settings" loading="lazy" src="/posts/pocket-llm-android/model_settings.png"></p>
<p>These are passed when creating the chat session, giving users control over the model&rsquo;s personality.</p>
<p>Gemma 3n uses ~3GB of memory.</p>
<h3 id="model-downloads">Model Downloads</h3>
<p>WorkManager handles:</p>
<ul>
<li>downloading the model from HuggingFace</li>
<li>progress updates</li>
<li>error handling</li>
<li>retries</li>
</ul>
<p>Credentials get stored with <code>EncryptedSharedPreferences</code>.</p>
<h2 id="performance">Performance</h2>
<p>Cold start: ~2-4 seconds depending on phone.
After that, responses are snappy.</p>
<ul>
<li>Flagships: <strong>smooth</strong></li>
<li>Mid-range: <strong>usable</strong></li>
<li>Low-end: don&rsquo;t bother, the model is too large.</li>
</ul>
<h2 id="what-i-learned">What I Learned</h2>
<ul>
<li>On-device LLMs are 100% usable today.</li>
<li>MediaPipe GenAI has clean APIs and surprisingly good docs.</li>
<li>Gemma 3n performs well for its size.</li>
</ul>
<p>The future is clear:
<strong>smaller models + better quantization = AI that lives on your device,
not on the cloud.</strong></p>
<p>Next steps for the project might include:</p>
<ul>
<li>streaming responses</li>
<li>conversation memory</li>
<li>multiple model support</li>
</ul>
<p>&hellip;but honestly even v1 is already super functional.</p>
<h2 id="try-it-yourself">Try It Yourself</h2>
<p>Full source code is here:
<strong><a href="https://github.com/jafforgehq/pocketllm">https://github.com/jafforgehq/pocketllm</a></strong></p>
<p>If you care about privacy, offline AI, or you just want to build
something cool with MediaPipe, give it a try. It&rsquo;s clean, fully tested,
and easy to extend.</p>
<p>This is the direction AI is moving, and it&rsquo;s fun to be early.</p>
]]></content:encoded></item><item><title>How to Run LLM Models with Ollama</title><link>https://jafforge.com/posts/running-llm-locally/</link><pubDate>Thu, 28 Aug 2025 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/running-llm-locally/</guid><description>Why local AI matters and how to run Ollama + Hugging Face models on your laptop — covering installation, model selection, quantization, and API integration.</description><content:encoded><![CDATA[<h2 id="why-run-ai-locally">Why Run AI Locally?</h2>
<p>Most people use ChatGPT or Gemini in the cloud, but what if you could run AI right on your own laptop or PC? That’s what I’ve been experimenting with lately, and I like it. And if you’re into self-hosting more broadly, tools like <a href="https://jafforge.com/posts/coolify/" title="Self-hosted platform">Coolify</a> make it just as easy to spin up your own AI services alongside other apps.</p>
<p>What I like after playing with this:</p>
<ul>
<li>Privacy: Prompts never leave your machine.</li>
<li>Cost: No monthly fees once models are downloaded.</li>
<li>Learning: You’ll touch concepts like context window and quantization instead of treating AI as a black box.</li>
</ul>
<h2 id="what-is-ollama">What Is Ollama?</h2>
<p>Ollama is a tool that lets you download and run open‑source LLMs locally with a simple CLI. You pull a model once, then chat with it offline.</p>
<p>Tip: The first run of a model downloads several GBs and can take a few minutes.</p>
<h2 id="install-ollama">Install Ollama</h2>
<p>macOS or Linux (Homebrew):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>brew install ollama
</span></span><span style="display:flex;"><span>ollama --version
</span></span></code></pre></div><p>Alternative (official script):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -fsSL https://ollama.com/install.sh | sh
</span></span></code></pre></div><p>Alternative (official website for macOS, Linux or Windows):
<a href="https://ollama.com/download/">https://ollama.com/download/</a></p>
<h2 id="run-your-first-model">Run Your First Model</h2>
<p>Start small with Gemma 2B:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama run gemma:2b
</span></span></code></pre></div><p>That command will pull the model if it’s not present, then open an interactive prompt.</p>
<p><img alt="Running a small model locally" loading="lazy" src="/posts/running-llm-locally/model.png"></p>
<h2 id="good-models-to-try">Good Models to Try</h2>
<ul>
<li><code>gemma3</code>: Latest Google model, available in multiple sizes (1B,4B,12B,27B).</li>
<li><code>llama3:8b</code>: Strong general-purpose baseline.</li>
<li><code>mistral:7b</code>:Efficient, good reasoning for size.</li>
<li><code>gemma:2b</code>: Fast and light for laptops.</li>
<li><code>phi3:mini</code>: Tiny but capable.</li>
</ul>
<p>See what you have locally:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama list
</span></span></code></pre></div><h2 id="specialized-models">Specialized Models</h2>
<p>Beyond general chat models, there are domain‑specialized models you can run locally:</p>
<ul>
<li>Coding: Trained on code and repos. Better at writing/reading code and following tool‑use prompts. Try <code>codellama:7b-instruct</code> as a lightweight starting point.</li>
<li>Medical/Legal/Finance: Domain‑tuned models exist on Hugging Face for specialized terminology and compliance language. Quality varies validate outputs and check licenses before use.</li>
<li>Vision: Multimodal models like <code>llava</code> let you ask questions about images (screenshots, charts, UI states).</li>
<li>Speech: <code>whisper</code> models handle local transcription without sending audio to cloud services.</li>
</ul>
<p>Tips:</p>
<ul>
<li>Prefer <code>*-instruct</code> variants for chat/assistant use.</li>
<li>Start with <code>q4</code> quantization for laptops increase to <code>q5/q8</code> if you have RAM and want quality.</li>
<li>Always test on your real tasks (sample codebase, sample note set, or representative documents).</li>
</ul>
<h2 id="quantization-explained">Quantization Explained</h2>
<p>When browsing models you’ll see two kinds of size info that are easy to mix up:</p>
<ul>
<li>
<p>Model size (e.g., 2B, 4B, 7B/8B, 12B, 70B): The number of trainable parameters. Bigger models generally reason better but need more memory and run slower.</p>
</li>
<li>
<p>Quantization (e.g., q4_0, q5_1, q8_0): How many bits are used per weight when loading the model. Lower bits = smaller memory footprint and faster load on CPUs, at the cost of some quality.</p>
</li>
</ul>
<p>Example tag: llama3:8b-instruct-q4_0</p>
<ul>
<li>
<p>8b: ~8 billion parameters (model capacity / quality indicator).</p>
</li>
<li>
<p>instruct: Chat-tuned variant for conversational use.</p>
</li>
<li>
<p>q4_0: 4-bit quantization preset (lighter memory use, faster inference).</p>
</li>
</ul>
<p>Memory Usage</p>
<p>Actual usage depends on quantization preset, loader, and whether you run on CPU or GPU but these ranges give you a feel:</p>
<ul>
<li>
<p>2–4B q4: ~1–2.5 GB</p>
</li>
<li>
<p>7–8B q4: ~3.5–5 GB</p>
</li>
<li>
<p>7–8B q8: ~7–9 GB</p>
</li>
<li>
<p>13B q4: ~6–8 GB</p>
</li>
</ul>
<p>Run a specific quantized build:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama run llama3:8b-instruct-q4_0
</span></span></code></pre></div><p>If a tag without quantization is used, Ollama selects a sensible default from the library you can always pin an explicit <code>q4/q5/q8</code> for predictability.</p>
<p>Browse models:</p>
<ul>
<li>Ollama Library: <a href="https://ollama.com/library">https://ollama.com/library</a></li>
<li>Hugging Face Hub: <a href="https://huggingface.co/models">https://huggingface.co/models</a></li>
</ul>
<h2 id="use-ollama-via-api">Use Ollama via API</h2>
<p>Ollama also exposes a local API, so you can call models from apps like JabRef or even wire it into your own projects. If you’re curious about the bigger picture, I wrote a post about <a href="https://jafforge.com/posts/power-of-mcp-for-ai-tools/" title="Model Context Protocol">MCP</a>
, which shows how standards like MCP make it easier to connect local LLMs with other AI tools in a consistent way.</p>
<p>Start the server (if not already running):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ollama server
</span></span></code></pre></div><p>Example curl call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -s http://localhost:11434/api/generate -d <span style="color:#e6db74">&#39;{
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;model&#34;: &#34;llama3:8b&#34;,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  &#34;prompt&#34;: &#34;Give me three AI project ideas.&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">}&#39;</span>
</span></span></code></pre></div><p>You can also use OpenAI-compatible clients by pointing the base URL to <code>http://localhost:11434/</code>.</p>
<h2 id="call-ollama-from-postman">Call Ollama from Postman</h2>
<p>Prefer to test APIs visually? You can call Ollama directly from Postman.</p>
<ul>
<li>Method: <code>POST</code></li>
<li>URL: <code>http://localhost:11434/api/generate</code></li>
<li>Headers: <code>Content-Type: application/json</code></li>
<li>Body (raw JSON):</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;model&#34;</span>: <span style="color:#e6db74">&#34;llama3:8b&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;prompt&#34;</span>: <span style="color:#e6db74">&#34;Summarize why local AI can be useful in 3 bullets.&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;stream&#34;</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If <code>stream</code> is <code>false</code>, Postman shows the full response at once. With <code>true</code>, you’ll see a stream of events. Here is how it looks in Postman:</p>
<p><img alt="Postman request to Ollama API" loading="lazy" src="/posts/running-llm-locally/postman.png"></p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li>Out of memory? Try a smaller or more quantized model (e.g., <code>llama3:8b-instruct-q4_0</code>).</li>
<li>Downloads slow? First pull can be several GB let it finish once.</li>
<li>Performance feels laggy? Close other heavy apps, or switch to a 2B–7B model.</li>
</ul>
<h2 id="use-gui-apps-no-terminal">Use GUI Apps (No Terminal)</h2>
<p>If you prefer a GUI app, you can use:</p>
<ul>
<li>AnythingLLM: <a href="https://anythingllm.com/">https://anythingllm.com/</a></li>
<li>LibreChat: <a href="https://www.librechat.ai/">https://www.librechat.ai/</a></li>
<li>LM Studio: <a href="https://lmstudio.ai/">https://lmstudio.ai/</a></li>
</ul>
]]></content:encoded></item><item><title>The Power of MCP for AI Tools</title><link>https://jafforge.com/posts/power-of-mcp-for-ai-tools/</link><pubDate>Thu, 03 Jul 2025 00:00:00 +0000</pubDate><guid>https://jafforge.com/posts/power-of-mcp-for-ai-tools/</guid><description>An intro to the Model Context Protocol: how it lets AI tools like Cursor query a Postgres database in natural language, with a full integration walkthrough.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>A major trend in the world of AI tools right now is something called the Model Context Protocol (MCP). After researching and implementing it in one of my sample projects, I wanted to share an overview of MCP in this blog post.</p>
<h2 id="what-is-model-context-protocol">What is Model Context Protocol</h2>
<p>Model Context Protocol (MCP) is a flexible standard that allows AI tools to connect not just to databases, but to a wide range of resources such as APIs, file systems, document stores, and even real-time event streams. Its goal is to make it easy for AI applications to interact with all sorts of data and services using a common approach, so you can add new capabilities without a lot of extra work. While MCP can power integrations with web services, files, and custom plugins, in this post I&rsquo;ll focus specifically on how it can be used to connect AI tools to databases, since that&rsquo;s where I&rsquo;ve found it most immediately useful and impactful. Another great feature is that it enables users who aren&rsquo;t familiar with writing queries to perform various operations on a database. Based on your request, it will automatically generate and execute the appropriate query directly on the database.</p>
<h2 id="first-thing-first-we-need-a-database">First thing first, we need a database</h2>
<p>To showcase this example, we need to create a database. We&rsquo;ll create a Postgres database and deploy it on our machine using the Coolify platform, which we covered in this article: <a href="https://jafforge.com/posts/coolify/">https://jafforge.com/posts/coolify/</a>. This allows us to deploy a database in just two clicks.</p>
<p><img alt="Database Creation" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/postgres_database.png"></p>
<p>Since this is a demo database, we&rsquo;ll create it with standard data and enable public access.</p>
<p><img alt="Database Creation" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/publicly_available.png"></p>
<p><strong>Disclaimer:</strong> Public access is not recommended for production apps, but it&rsquo;s acceptable for this example to showcase how it works.</p>
<h2 id="database-ready">Database Ready</h2>
<p>Now that our database is ready, we&rsquo;ll use DBeaver to connect and add some dummy data.</p>
<p><img alt="Database Creation" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/connection.png"></p>
<p>After connecting, we&rsquo;ll run the following query to create the table:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> users (
</span></span><span style="display:flex;"><span>    id SERIAL <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span>,
</span></span><span style="display:flex;"><span>    name VARCHAR(<span style="color:#ae81ff">100</span>),
</span></span><span style="display:flex;"><span>    email VARCHAR(<span style="color:#ae81ff">100</span>) <span style="color:#66d9ef">UNIQUE</span>,
</span></span><span style="display:flex;"><span>    city VARCHAR(<span style="color:#ae81ff">100</span>),
</span></span><span style="display:flex;"><span>    address VARCHAR(<span style="color:#ae81ff">150</span>),
</span></span><span style="display:flex;"><span>    age INT,
</span></span><span style="display:flex;"><span>    is_active BOOLEAN,
</span></span><span style="display:flex;"><span>    created_at <span style="color:#66d9ef">TIMESTAMP</span> <span style="color:#66d9ef">DEFAULT</span> <span style="color:#66d9ef">CURRENT_TIMESTAMP</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>Next, let&rsquo;s insert some dummy data:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> users (name, email)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">VALUES</span> 
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Diana Prince&#39;</span>, <span style="color:#e6db74">&#39;diana.prince@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Ethan Hunt&#39;</span>, <span style="color:#e6db74">&#39;ethan.hunt@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Frodo Baggins&#39;</span>, <span style="color:#e6db74">&#39;frodo.baggins@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Grace Hopper&#39;</span>, <span style="color:#e6db74">&#39;grace.hopper@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Hank Moody&#39;</span>, <span style="color:#e6db74">&#39;hank.moody@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Isla Fisher&#39;</span>, <span style="color:#e6db74">&#39;isla.fisher@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Jack Sparrow&#39;</span>, <span style="color:#e6db74">&#39;jack.sparrow@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Katniss Everdeen&#39;</span>, <span style="color:#e6db74">&#39;katniss.e@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Luke Skywalker&#39;</span>, <span style="color:#e6db74">&#39;luke.skywalker@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Maria Hill&#39;</span>, <span style="color:#e6db74">&#39;maria.hill@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Neo Anderson&#39;</span>, <span style="color:#e6db74">&#39;neo.anderson@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Olivia Wilde&#39;</span>, <span style="color:#e6db74">&#39;olivia.wilde@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Peter Parker&#39;</span>, <span style="color:#e6db74">&#39;peter.parker@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Quentin Beck&#39;</span>, <span style="color:#e6db74">&#39;quentin.beck@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Rachel Green&#39;</span>, <span style="color:#e6db74">&#39;rachel.green@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Steve Rogers&#39;</span>, <span style="color:#e6db74">&#39;steve.rogers@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Tony Stark&#39;</span>, <span style="color:#e6db74">&#39;tony.stark@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Uma Thurman&#39;</span>, <span style="color:#e6db74">&#39;uma.thurman@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Viktor Reznov&#39;</span>, <span style="color:#e6db74">&#39;viktor.reznov@example.com&#39;</span>),
</span></span><span style="display:flex;"><span>  (<span style="color:#e6db74">&#39;Wanda Maximoff&#39;</span>, <span style="color:#e6db74">&#39;wanda.maximoff@example.com&#39;</span>);
</span></span></code></pre></div><p>Now let&rsquo;s check the database to see if all the data was saved correctly.</p>
<p><img alt="Table in DBeaver" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/users_table.png"></p>
<p>Everything looks good. Now let&rsquo;s proceed with adding this database to Cursor AI via MCP. (I&rsquo;m using Cursor AI since I&rsquo;m currently experimenting with it, but the logic is similar for all AI tools that support MCP.)</p>
<h2 id="adding-postgres-via-mcp-to-cursor">Adding postgres via MCP to Cursor</h2>
<p>First, open Cursor IDE, go to Settings, and navigate to Tools and Integration.</p>
<p><img alt="Tools &amp; Integrations" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/tools_integration.png"></p>
<p>Add a new MCP server and fill in all the details.</p>
<p><img alt="Postgres Connection" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/postgres_connection.png"></p>
<p><strong>Note:</strong> For the connection, you can either provide a full connection string (including host, password, port, user, etc.) or specify a config object. Cursor supports both. For this example, I used a connection string.</p>
<p>Once saved, if all credentials are correct, Cursor will be able to access your database.</p>
<p>Let&rsquo;s retrieve the tables present</p>
<p><img alt="Connection Succesful" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/init_query.png"></p>
<p>Now let&rsquo;s retrieve all users from users table
<img alt="Results" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/results.png"></p>
<p>Next, let&rsquo;s showcase an example of filtering:
<img alt="Filtered" loading="lazy" src="/posts/power-of-mcp-for-ai-tools/filtered.png"></p>
<p><strong>Note:</strong> MCP Postgres connections only support read operations. The database is treated as a read-only model source.</p>
<h2 id="wrap-up">Wrap up</h2>
<p>As you can see, MCP is a really interesting concept that enables us to create new data sources for our AI agents. This technically allows both engineers and non engineers to use natural language to execute any possible query on a database without writing a single line of SQL.</p>
<p>I&rsquo;ve tested this on another project, and the Postgres MCP integration can even suggest improvements to your tables or queries, which can have a significant impact on large databases.</p>
<p>All in all, I expect that more and more tools will create their own versions of MCP servers that you can integrate into your favorite AI IDEs. You can even create your own MCP if you want!</p>
<p>That&rsquo;s all for now. Thank you for your attention!</p>
]]></content:encoded></item><item><title>Coolify on Hetzner</title><link>https://jafforge.com/posts/coolify/</link><pubDate>Mon, 09 Jun 2025 21:53:26 +0200</pubDate><guid>https://jafforge.com/posts/coolify/</guid><description>A step-by-step walkthrough for setting up Coolify on a Hetzner VPS so you can self-host a blog, databases, n8n, and AI agents on a single server.</description><content:encoded><![CDATA[<p>I’ve recently been getting deeper into backend development and cross-platform mobile using Kotlin Multiplatform. On the side, I’ve been playing with tools like n8n, setting up databases, and building small AI agents and automation workflows. Pretty quickly, I realized I needed a solid self-hosting setup where I could run everything, from simple web apps to AI agents, and backend services, without relying on multiple different platforms. I used Heroku and Vercel before, but for someone mostly working on hobby projects, their pricing and limitations didn’t make much sense long-term.</p>
<p>That&rsquo;s when I found <a href="https://coolify.io">Coolify</a> and set it up on a Hetzner VPS - simple, powerful, and fully under my control. Technically, you could host all of this on something like a Raspberry Pi, an old laptop, or whatever, but for now, let&rsquo;s keep it on Hetzner.</p>
<p>So, let’s dive into the setup process.</p>
<h3 id="hetzner">Hetzner</h3>
<p>As I already said, for our server we will use a Hetzner VPS, this one in particular:</p>
<p><img alt="Hetzner" loading="lazy" src="/posts/coolify/vps.png"></p>
<p>As you can see, it is a VPS with 4 vCPU, 8 GB RAM, and 80 GB SSD with 20 TB traffic included. This is more than enough for my needs. There are even cheaper options like CX11 starting from 4€ per month, but I think this is a good balance between price and performance. After you choose your machine, Linux version, and location, we can proceed. I chose Ubuntu 24.04 (latest) and Helsinki as the location.</p>
<h3 id="vps-setup">VPS Setup</h3>
<p>Once my Hetzner VPS was up, I connected via SSH using the root user. The first thing I did was update all system packages to ensure everything was up to date:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt update <span style="color:#f92672">&amp;&amp;</span> sudo apt upgrade -y
</span></span></code></pre></div><p>Since I am not a Linux expert at all, I did some Googling and getting guidance from ChatGPT and found some best practices to secure my machine. First, I changed the default SSH port and disabled root login so I am only able to access my server via SSH.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo nano /etc/ssh/sshd_config
</span></span></code></pre></div><p>And changed the following lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>Port <span style="color:#75715e">#Some random port above 1024</span>
</span></span><span style="display:flex;"><span>PermitRootLogin prohibit-password
</span></span></code></pre></div><p>After that, I restarted the SSH service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl restart sshd
</span></span></code></pre></div><p>There are other things I could do, like creating a new user and adding it to the sudo group, but for now, I think this setup is enough. Anyway, I have snapshots enabled so I can always restore my system to a previous state if something goes wrong. For now, we can say that we are ready to install Coolify. There&rsquo;s also a great firewall built into the Hetzner console where you can open any port needed. I skipped setting up UFW directly on the Linux machine and instead, I am using the one integrated in the Hetzner console. I guess two firewalls would be overkill for now.</p>
<h3 id="coolify-setup">Coolify Setup</h3>
<p>Run this in the terminal:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash
</span></span></code></pre></div><p>This is how it will look after installation:</p>
<p><img alt="Coolify" loading="lazy" src="/posts/coolify/coolify_congratulation.png"></p>
<p>After that, you need to follow the onboarding steps and create an admin user, and then you can access Coolify at <code>http://&lt;your-server-ip&gt;:8080</code></p>
<p><img alt="Coolify" loading="lazy" src="/posts/coolify/coolify_dashboard.png"></p>
<p>Now for the sake of not breaking anything, I used a neat feature of Hetzner to create a snapshot backup that I can restore anytime:</p>
<p><img alt="Hetzner" loading="lazy" src="/posts/coolify/snapshot_backup.png"></p>
<p>P.S. I also added a custom domain to Coolify and enabled 2FA (I am really surprised that they have 2FA available for free). Also, Coolify is handling free SSL certificates for your domain and automatically renewing them.</p>
<p>Basically, to add a custom domain, you just need to add it in Coolify under Settings/Instance Settings/Instance Domain and adjust your DNS records to point to your server IP, depending on your domain registrar.</p>
<p>Add an A record pointing to your VPS IP and, optionally, a CNAME if you&rsquo;re using subdomains.</p>
<p><img alt="Coolify" loading="lazy" src="/posts/coolify/custom_domain.png"></p>
<p>After that, you can access Coolify at <code>https://yourdomain</code></p>
<p>That’s it Coolify is up and running, and you’re ready to start deploying projects. If you&rsquo;re thinking about self-hosting your own apps, I highly recommend trying this setup.</p>
<h3 id="benefits">Benefits</h3>
<p>One of the things I really like about Coolify is how easy it is to connect it with GitHub. I set it up so this blog gets automatically deployed every time I push to the main branch.</p>
<p>Right now, I have all of this running at the same time on the same server:</p>
<ul>
<li>
<p>This blog (Hugo static site)</p>
</li>
<li>
<p>An n8n instance</p>
</li>
<li>
<p>A backend app built with Spring Boot</p>
</li>
<li>
<p>A Postgres database</p>
</li>
</ul>
<p>So in summary, Coolify gives you:</p>
<ul>
<li>
<p>Push-to-deploy workflows for your code</p>
</li>
<li>
<p>Instant-ready runtimes for a wide range of languages and frameworks</p>
</li>
<li>
<p>One-click apps + managed databases</p>
</li>
</ul>
<p>It’s genuinely everything you need for a modern self-hosted PaaS.</p>
<p>That’s all for now. I’ll keep sharing more as I continue building, experimenting, and learning new things along the way. Whether it&rsquo;s another backend app, some automation with AI, or just a fun side project, I plan to document it here. Until the next cool thing, thanks for reading and see you in the next post!</p>
]]></content:encoded></item></channel></rss>