<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ak1.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ak1.io/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-06-28T14:19:05+00:00</updated><id>https://ak1.io/feed.xml</id><title type="html">Akshay Sharma</title><subtitle>Akshay Sharma — software architect, open-source maintainer, and Kotlin Multiplatform developer. Writing on software craft, architecture, product quality, and the small decisions that make software easier to use.</subtitle><author><name>Akshay Sharma</name><email>akshay@redigit.io</email></author><entry><title type="html">Giving Claude Memory of My Own Files — Locally</title><link href="https://ak1.io/blog/2026/06/28/ucp-local-mcp-for-claude/" rel="alternate" type="text/html" title="Giving Claude Memory of My Own Files — Locally" /><published>2026-06-28T00:00:00+00:00</published><updated>2026-06-28T00:00:00+00:00</updated><id>https://ak1.io/blog/2026/06/28/ucp-local-mcp-for-claude</id><content type="html" xml:base="https://ak1.io/blog/2026/06/28/ucp-local-mcp-for-claude/"><![CDATA[<p>A few weeks ago I asked Claude something I was almost certain I’d asked it before — same problem, same project, a month earlier. The reply was useful, but it was also clearly a reply from someone meeting the question for the first time. Every prior session had evaporated. At the same time, the things that <em>would</em> have answered the question — my notes folder, the project’s README, a transcript from a meeting — were sitting on my own disk, where Claude couldn’t reach them.</p>

<p>Two gaps, same shape. The model is great at reasoning over whatever lands in its context, and useless at the part where context actually comes from.</p>

<p>So I built <a href="https://github.com/akshay2211/universal-context-pipeline">UCP</a> — Universal Context Pipeline. A single Rust binary that indexes folders on my machine, then exposes them to any MCP client (Claude Desktop, Cursor, LM Studio, Zed, Continue, custom agents) as one tool: <code class="language-plaintext highlighter-rouge">search_local_context</code>. No cloud, no telemetry, no per-page-view API calls.</p>

<h2 id="what-it-actually-is">What it actually is</h2>

<p>UCP is a local-first <a href="https://modelcontextprotocol.io/">MCP</a> server. You point it at folders — notes, code, exported Claude conversations, PDFs — and it builds a hybrid index on disk. When an MCP client launches it, the client sees exactly one new tool, and the LLM running in that client can call it whenever it needs grounding.</p>

<p>The retrieval underneath is the unglamorous part that quietly matters:</p>

<ul>
  <li><strong>BM25 via SQLite FTS5</strong> for exact terms (function names, error strings, that one weird acronym).</li>
  <li><strong>Vector search via <code class="language-plaintext highlighter-rouge">sqlite-vec</code></strong> for intent (“the thing that retries webhooks with exponential backoff”).</li>
  <li><strong>Reciprocal-rank fusion</strong> to merge the two — neither alone is good enough; together they handle both shapes of query.</li>
  <li><strong>Tree-sitter chunking</strong> for Rust / Python / TS-JS, heading-aware chunking for Markdown, sentence-bounded fallback for everything else.</li>
  <li><strong>Content-hash embedding cache.</strong> Re-index a folder where nothing changed and zero embedding calls are made.</li>
</ul>

<p>Everything lives in one SQLite file. The whole thing is one binary on your <code class="language-plaintext highlighter-rouge">PATH</code>.</p>

<h2 id="the-design-call-i-keep-defending-one-tool-not-ten">The design call I keep defending: one tool, not ten</h2>

<p>Most “context for LLMs” tools expose a constellation of MCP tools — <code class="language-plaintext highlighter-rouge">read_notes</code>, <code class="language-plaintext highlighter-rouge">search_code</code>, <code class="language-plaintext highlighter-rouge">find_conversations</code>, <code class="language-plaintext highlighter-rouge">list_pdfs</code>, and so on. I went the other way. UCP exposes exactly one tool to the model: <code class="language-plaintext highlighter-rouge">search_local_context(query, folder_filter?, limit?)</code>.</p>

<p>The reason is empirical, not philosophical. Agents have to <em>choose</em> a tool to call, and tool choice gets worse — sometimes much worse — as you add more tools that overlap in meaning. “Should I <code class="language-plaintext highlighter-rouge">search_code</code> or <code class="language-plaintext highlighter-rouge">read_notes</code> for this question?” is the kind of decision an LLM can faceplant on, and it’s the kind of decision the <em>retriever</em> should be making anyway, because it’s the one with the index in front of it.</p>

<p>So UCP collapses the decision: the model asks one question in natural language, the retriever decides whether to lean on BM25 or vectors or both, the model gets snippets with citations back. There’s nothing for the agent to get wrong because there’s nothing to choose.</p>

<p>The cost is that the tool description has to do real work — it has to tell the model when calling it is appropriate and when it isn’t. That’s a writing problem, not a protocol problem, and I’d rather solve it once in a docstring than push it into the model’s tool-routing every turn.</p>

<h2 id="why-local-first-beyond-the-obvious">Why local-first, beyond the obvious</h2>

<p>The privacy story writes itself — lawyers, clinicians, defense, anyone with an NDA, anyone who simply doesn’t want their notes leaving the laptop. Pair UCP with a local model in LM Studio or Ollama and the whole stack — indexing, embeddings, retrieval, chat model — runs offline. Works on a plane. Works in an air-gapped facility. Works when the WiFi at the café decides today is not the day.</p>

<p>But the part I didn’t expect was that <strong>local-first is what makes the conversation-memory feature work at all</strong>. Exporting your Claude history and uploading it to a third-party “memory” service is a non-starter for most people — it’s the most intimate corpus you have. Doing it locally turns it from an awkward sell into a one-liner: <code class="language-plaintext highlighter-rouge">ucp-local ingest-conversations ~/Downloads/claude-export/conversations.json</code>. From that point on, every future Claude (or Cursor, or LM Studio) session can search every prior one. The thing that was missing the day I started building this — Claude remembering what we already worked through — became a <code class="language-plaintext highlighter-rouge">search_local_context</code> call.</p>

<h2 id="the-bit-im-quietly-happy-with">The bit I’m quietly happy with</h2>

<p>The content-hash embedding cache. Embeddings are the expensive step — they hit Ollama, they take real wall-time, and the typical edit-a-file-and-re-index workflow re-touches a lot of chunks whose content hasn’t actually changed. So every chunk is hashed; the hash is the cache key; on re-index, only chunks with a new hash hit the embedding model. The watcher (<code class="language-plaintext highlighter-rouge">ucp-local watch</code>) leans on this — you edit a file, the index updates in ~500ms because 99% of the chunks were already embedded last time.</p>

<p>It’s a small thing. It also turns “re-index my big notes folder” from a coffee break into a beat.</p>

<h2 id="the-part-that-actually-hurt">The part that actually hurt</h2>

<p>PDFs. Specifically, PDFs whose body fonts don’t ship a ToUnicode CMap.</p>

<p>The Rust <code class="language-plaintext highlighter-rouge">pdf-extract</code> crate is fine for most things, but on a particular shape of PDF — usually papers and corporate exports — it’ll happily pull out the headings and lose the entire body. The text isn’t missing from the file; it’s just encoded in a way the extractor can’t map back to characters without the CMap. You only notice when you query the index for a phrase you know is in the document and get nothing back.</p>

<p>The fix wasn’t writing a better PDF parser (that way lies madness). It was admitting that <code class="language-plaintext highlighter-rouge">pdftotext</code> from Poppler already solves this and giving UCP a graceful fallback: try <code class="language-plaintext highlighter-rouge">pdf-extract</code> first, detect the “headings only, body missing” failure mode, fall back to <code class="language-plaintext highlighter-rouge">pdftotext</code> if it’s on <code class="language-plaintext highlighter-rouge">PATH</code>. Poppler is now a documented optional dependency that I quietly recommend everyone install.</p>

<p>The lesson, again, was the unglamorous one: the hard part of grounding an LLM in your own files isn’t the model or the embeddings or the retrieval algorithm. It’s the long tail of file formats refusing to give up their text.</p>

<h2 id="how-you-can-try-it">How you can try it</h2>

<p>Three commands on macOS, then a four-line MCP config:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>ollama poppler
ollama serve &amp;
ollama pull nomic-embed-text
cargo <span class="nb">install </span>ucp-local
ucp-local index ~/Documents/notes ~/code/my-project
</code></pre></div></div>

<p>Then add UCP to <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Claude/claude_desktop_config.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"ucp-local"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/full/path/to/ucp-local"</span><span class="p">,</span><span class="w"> </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"serve"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Restart Claude Desktop. Ask it something grounded in your indexed folders. The reply will cite the files.</p>

<p>If you want the conversation-memory unlock too, export your Claude history from <code class="language-plaintext highlighter-rouge">claude.ai/settings/data-privacy-controls</code> and run <code class="language-plaintext highlighter-rouge">ucp-local ingest-conversations ~/Downloads/claude-export/conversations.json</code>. Every future Claude session now has access to every past one. That, more than anything else UCP does, is the part I didn’t realise I wanted until I had it.</p>

<p>The full README, MCP configs for Cursor and LM Studio, and the positioning doc are all on <a href="https://github.com/akshay2211/universal-context-pipeline">GitHub</a>. v0.1 is on <a href="https://crates.io/crates/ucp-local">crates.io</a> as <code class="language-plaintext highlighter-rouge">ucp-local</code>.</p>

<aside class="repo-star-cta">
  <div class="repo-star-cta-text">
    <p class="eyebrow">Found this useful?</p>
    <p>If <strong>UCP</strong> ends up grounding your Claude or Cursor sessions, a star on GitHub is the easiest way to say thanks — and it helps other folks in privacy-sensitive workflows find it.</p>
  </div>
  <a class="repo-star-button" href="https://github.com/akshay2211/universal-context-pipeline" target="_blank" rel="noopener" aria-label="Star universal-context-pipeline on GitHub">
    <span class="repo-star-button-action">
      <svg class="gh-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
      <svg class="star-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" /></svg>
      Star
    </span>
    <span class="repo-star-button-repo">akshay2211/universal-context-pipeline</span>
  </a>
</aside>]]></content><author><name>Akshay Sharma</name></author><category term="rust" /><category term="mcp" /><category term="llm" /><category term="local-first" /><category term="side project" /><summary type="html"><![CDATA[Why I built UCP, a local-first MCP server in Rust that turns notes, code, and every past Claude chat into one searchable tool — and the design call I keep getting asked about.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ak1.io/img/blog/universal-context-pipeline.svg" /><media:content medium="image" url="https://ak1.io/img/blog/universal-context-pipeline.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">DrawBox Goes Multiplatform</title><link href="https://ak1.io/blog/2026/06/23/drawbox-goes-multiplatform/" rel="alternate" type="text/html" title="DrawBox Goes Multiplatform" /><published>2026-06-23T00:00:00+00:00</published><updated>2026-06-23T00:00:00+00:00</updated><id>https://ak1.io/blog/2026/06/23/drawbox-goes-multiplatform</id><content type="html" xml:base="https://ak1.io/blog/2026/06/23/drawbox-goes-multiplatform/"><![CDATA[<p><a href="https://github.com/akshay2211/DrawBox">DrawBox</a> has been an Android library since December 2021. A pen tool, an undo stack, a way to save what you drew — small surface area, did what it said. People picked it up, filed issues, sent PRs. It was happy living in <code class="language-plaintext highlighter-rouge">androidMain</code> and I was happy leaving it there.</p>

<p>Then my own work stopped being Android-only.</p>

<blockquote>
  <p><strong>Try it in your browser:</strong> the WASM build of the sample app is live at <strong><a href="https://akshay2211.github.io/DrawBox/sample/">akshay2211.github.io/DrawBox/sample</a></strong> — no install, just draw.</p>
</blockquote>

<h2 id="the-honest-reason-for-the-rewrite">The honest reason for the rewrite</h2>

<p>Nobody filed a “please port to iOS” issue. The push was personal: my day-to-day moved into Kotlin Multiplatform across Android, iOS, Web and Desktop, and it started feeling silly that my own drawing library was the one thing I couldn’t reuse. A stroke is a stroke. An undo entry is an undo entry. The geometry, the serialization, the gesture interpretation — none of it has anything to do with Android specifically.</p>

<p>So the question stopped being <em>should this be multiplatform</em> and became <em>why is it still not</em>.</p>

<p>The refactor commit landed in late May 2026. The library is now <code class="language-plaintext highlighter-rouge">DrawBox 2.0</code>, shipped as a Compose Multiplatform artifact with Android, iOS, JVM/Desktop and WASM targets from one shared source set.</p>

<h2 id="what-actually-lives-in-commonmain">What actually lives in <code class="language-plaintext highlighter-rouge">commonMain</code></h2>

<p>Almost everything. The shared module is split the way you’d expect if you’ve done this before:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">domain/model</code> — <code class="language-plaintext highlighter-rouge">State</code>, <code class="language-plaintext highlighter-rouge">Intent</code>, <code class="language-plaintext highlighter-rouge">Event</code>, <code class="language-plaintext highlighter-rouge">Element</code>, <code class="language-plaintext highlighter-rouge">Geometry</code>, <code class="language-plaintext highlighter-rouge">Viewport</code>, <code class="language-plaintext highlighter-rouge">Serialization</code>, <code class="language-plaintext highlighter-rouge">BackgroundPattern</code></li>
  <li><code class="language-plaintext highlighter-rouge">domain/usecase</code> — <code class="language-plaintext highlighter-rouge">SvgExporter</code>, the rest of the use cases</li>
  <li><code class="language-plaintext highlighter-rouge">presentation/viewmodel</code> — <code class="language-plaintext highlighter-rouge">DrawBoxController</code></li>
  <li><code class="language-plaintext highlighter-rouge">presentation/reducer</code> — one <code class="language-plaintext highlighter-rouge">Reducer.kt</code>, about 250 lines, where every state transition happens</li>
</ul>

<p>The platform source sets — <code class="language-plaintext highlighter-rouge">androidMain</code>, <code class="language-plaintext highlighter-rouge">iosMain</code>, <code class="language-plaintext highlighter-rouge">jvmMain</code>, <code class="language-plaintext highlighter-rouge">wasmJsMain</code> — are thin. They exist to bridge things the Kotlin stdlib won’t give you cross-platform: file IO, share sheets, the few graphics primitives that still differ. The drawing logic itself doesn’t know what platform it’s on.</p>

<h2 id="the-part-that-actually-hurt">The part that actually hurt</h2>

<p>This is the section the rewrite earned. Going from Android-only to KMP was the easy framing. The real work was rewriting the whole thing around <strong>MVI and a single immutable state</strong>.</p>

<p>The original DrawBox was a controller with mutable state — perfectly fine on one platform. Compose Multiplatform rewards a different model: one <code class="language-plaintext highlighter-rouge">State</code> data class, an <code class="language-plaintext highlighter-rouge">Intent</code> sealed type for every user action, a <code class="language-plaintext highlighter-rouge">Reducer</code> that takes <code class="language-plaintext highlighter-rouge">(State, Intent)</code> and returns the next <code class="language-plaintext highlighter-rouge">State</code>. Clean once it’s done. Painful while you’re getting there, because <em>every</em> feature has to be re-expressed as an intent and a reducer case. Pen-down, shape resize, eraser hit-test, viewport zoom, history step, JSON load — all of it funnels through the same 250 lines.</p>

<p>The payoff was the bit I underestimated:</p>

<ul>
  <li><strong>Undo/redo is basically free.</strong> History is just a stack of <code class="language-plaintext highlighter-rouge">State</code>. The reducer doesn’t have to know it exists.</li>
  <li><strong>Serialization is free.</strong> If <code class="language-plaintext highlighter-rouge">State</code> is <code class="language-plaintext highlighter-rouge">@Serializable</code>, then JSON import/export is mostly already done.</li>
  <li><strong>Replay is free.</strong> The sample app has a replay screen because the architecture allowed it, not because I planned one.</li>
  <li><strong>Platforms can’t drift.</strong> WASM, iOS, Android and Desktop call the same reducer on the same state. If the eraser behaves differently on one of them, it’s a rendering bug, not a logic bug — and the bug surface shrinks accordingly.</li>
</ul>

<p>What it cost was the willingness to put the library on pause and rebuild features I’d already shipped, with no new user-visible win until the very end.</p>

<h2 id="what-snuck-in-once-the-model-was-clean">What snuck in once the model was clean</h2>

<p>Once everything was an <code class="language-plaintext highlighter-rouge">Element</code> in a <code class="language-plaintext highlighter-rouge">State</code>, adding features got noticeably cheap. The KMP branch picked up a lot more than just new targets:</p>

<ul>
  <li>SVG export and JSON import/export</li>
  <li>Infinite canvas with zoom and pan</li>
  <li>Shape selection, drag and scale</li>
  <li>Connectors between shapes</li>
  <li>Stroke style and corner radius options</li>
  <li>A tileable SVG background pattern with optional tint</li>
  <li>An object eraser tool (replaces pan in the controls bar)</li>
  <li>Roborazzi for visual regression tests</li>
</ul>

<p>Most of these would have been awkward in the old architecture. In the new one, they’re a few lines in the reducer and an entry on the toolbar — and they ship on every platform at once. There’s also <a href="https://akshay2211.github.io/DrawBox/sample/">a live WASM sample</a> now, which has done more for “try it before you adopt it” than any README screenshot ever did.</p>

<h2 id="would-i-do-it-again">Would I do it again</h2>

<p>Yes, but I’d stop pretending the migration paid off on merge day. It didn’t. It paid off over the weeks after, when each new feature was small, symmetric across platforms, and didn’t need a per-target branch.</p>

<p>If you maintain an Android-only library and your own work has quietly moved into KMP, the library is going to follow whether you plan for it or not. Better to do it deliberately than to let it slowly stop reflecting how you actually build things now.</p>

<aside class="repo-star-cta">
  <div class="repo-star-cta-text">
    <p class="eyebrow">Liked the rewrite?</p>
    <p>If <strong>DrawBox 2.0</strong> finds its way into something you're shipping — Android, iOS, Desktop or Web — a star on GitHub is the easiest way to say thanks, and it genuinely helps other folks discover the library.</p>
  </div>
  <a class="repo-star-button" href="https://github.com/akshay2211/DrawBox" target="_blank" rel="noopener" aria-label="Star DrawBox on GitHub">
    <span class="repo-star-button-action">
      <svg class="gh-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
      <svg class="star-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" /></svg>
      Star
    </span>
    <span class="repo-star-button-repo">akshay2211/DrawBox</span>
  </a>
</aside>]]></content><author><name>Akshay Sharma</name></author><category term="kotlin multiplatform" /><category term="compose multiplatform" /><category term="drawbox" /><category term="mvi" /><summary type="html"><![CDATA[Why I rewrote my Android-only drawing library for Kotlin Multiplatform, and the part of the rewrite that actually hurt.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ak1.io/img/blog/drawbox-goes-multiplatform.svg" /><media:content medium="image" url="https://ak1.io/img/blog/drawbox-goes-multiplatform.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">statsvg_rs: GitHub Stats Cards I Actually Control</title><link href="https://ak1.io/blog/2026/06/22/statsvg-rs-github-stats-cards/" rel="alternate" type="text/html" title="statsvg_rs: GitHub Stats Cards I Actually Control" /><published>2026-06-22T00:00:00+00:00</published><updated>2026-06-22T00:00:00+00:00</updated><id>https://ak1.io/blog/2026/06/22/statsvg-rs-github-stats-cards</id><content type="html" xml:base="https://ak1.io/blog/2026/06/22/statsvg-rs-github-stats-cards/"><![CDATA[<p>For a while my GitHub profile had a small pile of stats cards on it. One for streaks, one for top languages, one for repo counts. Each came from a different service, each looked slightly different, and none of them could be told to show <em>exactly</em> what I wanted. If I needed a compact card for a project README and a fuller one for my profile page, I was out of luck — the tools gave me one shape and that was that.</p>

<p>So I built <a href="https://github.com/akshay2211/statsvg_rs">statsvg_rs</a>: a single Rust program that renders GitHub stats cards as SVG, with enough knobs that one tool can produce a header-heavy profile card <em>and</em> a stripped-down lifetime-stats card from the same code.</p>

<h2 id="what-it-actually-makes">What it actually makes</h2>

<p>There are two presets, and the difference is the whole point.</p>

<p><strong><code class="language-plaintext highlighter-rouge">profile.svg</code></strong> is the card you drop into a project README, where the reader doesn’t know who you are yet. It leans on identity: avatar, bio, location, a row of last-year stats, a contribution grid, top languages, and pinned repos.</p>

<p><strong><code class="language-plaintext highlighter-rouge">stats.svg</code></strong> is what I call the anti-profile. It’s meant for your profile README — the <code class="language-plaintext highlighter-rouge">username/username</code> repo — where your avatar and bio are already sitting right above it. So it drops all of that and instead shows the things GitHub tends to hide: <strong>lifetime contributions since you joined</strong>, your longest streak, all-time stars and commits, the top repos you’ve contributed to but don’t own, and your single most-starred project as a highlight.</p>

<p>Both are driven by the same flags. Width, theme (<code class="language-plaintext highlighter-rouge">github_dark</code>, <code class="language-plaintext highlighter-rouge">nord</code>, <code class="language-plaintext highlighter-rouge">dracula</code>, <code class="language-plaintext highlighter-rouge">light</code>, <code class="language-plaintext highlighter-rouge">solarized</code>), which sections to show or hide, how many pinned repos, an optional highlight line. If you want a profile card with no contribution grid, that’s one flag. If you want the lifetime variant but with the header back on, that’s one flag too. That flexibility is the feature I couldn’t find anywhere else.</p>

<h2 id="how-it-works">How it works</h2>

<p>The flow is short and boring in a good way, which is what I wanted.</p>

<ol>
  <li><strong>Fetch.</strong> One GraphQL query to GitHub pulls the user, their repos, languages, contribution calendar, pinned items, and contributed-to repos. The lifetime variant fires a few extra queries — more on that below.</li>
  <li><strong>Compute.</strong> From that raw data it derives the numbers: total stars and forks, language percentages by bytes of code, current and longest streak from the calendar, and the last ~18 weeks of the contribution grid.</li>
  <li><strong>Render.</strong> It builds the SVG as a plain string, section by section, top to bottom. There’s no templating engine and no headless browser — just a small builder that keeps a running vertical cursor and writes one section after another.</li>
</ol>

<p>A couple of details I’m quietly happy with. The avatar is fetched and <strong>base64-embedded directly into the SVG</strong>, so the card is fully self-contained — no external image request when someone loads it. And themes are just plain Rust structs; adding a new one is a constant declaration and a single line to register it, no config format to invent.</p>

<p>For the lifetime numbers there’s a wrinkle worth calling out. GitHub’s API only gives you contribution totals for a date range, not a true “since the beginning of time” number. So to get a real lifetime total, statsvg_rs asks for each year from your join date to now — one query per year — and sums them. The per-year requests fan out concurrently so it stays fast even for an account that’s been around a decade.</p>

<h2 id="how-i-deploy-it-and-why">How I deploy it, and why</h2>

<p>Here’s the part I went back and forth on. The project can run as a live HTTP server — there’s an axum server mode and a Dockerfile — but I don’t deploy it that way. I render to static files instead.</p>

<p>A GitHub Action runs on a schedule (every six hours), builds the binary, renders both <code class="language-plaintext highlighter-rouge">profile.svg</code> and <code class="language-plaintext highlighter-rouge">stats.svg</code>, generates a tiny landing page, and publishes the whole thing to GitHub Pages. The cards you embed are just static files sitting on a CDN.</p>

<p>I picked this for two plain reasons:</p>

<ul>
  <li><strong>There’s nothing to keep alive.</strong> No server to pay for, monitor, or restart at 2am. A scheduled job either runs or it doesn’t, and if it doesn’t, the last good card is still sitting there.</li>
  <li><strong>It’s faster and more reliable for whoever’s looking at it.</strong> A static SVG from a CDN always loads instantly. A live server has cold starts, can go down, and gets hit on every single README view — which is exactly how the shared instances of other stats tools end up rate-limited and broken.</li>
</ul>

<p>Rendering on a schedule means GitHub’s API gets called a handful of times a day on my terms, not once per page view by every visitor. The server mode still earns its keep, though — it’s how I iterate on layout locally. <code class="language-plaintext highlighter-rouge">cargo run</code>, hit <code class="language-plaintext highlighter-rouge">localhost:3000</code> with different query params, and watch the card change without waiting on a full render-and-deploy cycle.</p>

<h2 id="how-you-can-use-it">How you can use it</h2>

<p>If you want your own copy, it’s a fork-and-edit job:</p>

<ol>
  <li>Fork the repo.</li>
  <li>Open <code class="language-plaintext highlighter-rouge">.github/workflows/render.yml</code> and set <code class="language-plaintext highlighter-rouge">STATSVG_USER</code> to your GitHub login (and a theme/width if you like).</li>
  <li>Turn on GitHub Pages with the source set to <strong>GitHub Actions</strong>.</li>
  <li>Push. Your cards publish to <code class="language-plaintext highlighter-rouge">https://&lt;you&gt;.github.io/&lt;repo&gt;/profile.svg</code> and <code class="language-plaintext highlighter-rouge">/stats.svg</code>.</li>
</ol>

<p>If you want private-repo data counted, generate a classic token with <code class="language-plaintext highlighter-rouge">repo</code> scope and add it as a repo secret — otherwise it just uses public data. Then embed whichever card fits where you’re putting it.</p>

<p>That last part is how I run it myself: the <strong>stats card lives on my profile README at <code class="language-plaintext highlighter-rouge">akshay2211/akshay2211</code></strong>, and the <strong>profile card sits in the README of my <a href="https://github.com/akshay2211/DrawBox">DrawBox</a> project</strong>. Same tool, two genuinely different cards, each tuned for where it’s shown.</p>

<h2 id="what-was-actually-hard">What was actually hard</h2>

<p>Honestly? Not much fought me. The mechanics — GraphQL, the SVG building, the Actions pipeline — mostly just worked once they were wired up. The real work wasn’t debugging, it was <em>deciding</em>: what belongs on a profile card versus a stats card, what’s noise, what GitHub already shows the viewer so I shouldn’t repeat it. The anti-profile idea came out of that question, not out of any technical struggle.</p>

<p>The few things I had to design <em>around</em> rather than fight were all just realities of the platform. Lifetime totals needing a query per year, as mentioned. GitHub’s image proxy caching embedded SVGs, which is part of why re-rendering every six hours (rather than chasing real-time) is the right cadence — and why there’s a <code class="language-plaintext highlighter-rouge">?v=</code> cache-bust trick in the README for when you want a card refreshed immediately. And the layout being hand-tracked rather than handed to a layout engine, which is more arithmetic but also means there’s no surprise dependency between me and the pixels.</p>

<p>If you’ve got a wall of mismatched cards on your profile and you’ve ever wished one of them did something slightly different, that’s the itch this scratches. The code is on <a href="https://github.com/akshay2211/statsvg_rs">GitHub</a> — fork it, point it at your username, and make it show what you actually want.</p>

<aside class="repo-star-cta">
  <div class="repo-star-cta-text">
    <p class="eyebrow">Found this useful?</p>
    <p>If <strong>statsvg_rs</strong> ends up rendering the cards on your profile, a star on GitHub is the easiest way to say thanks — and it nudges the repo into other people's search results too.</p>
  </div>
  <a class="repo-star-button" href="https://github.com/akshay2211/statsvg_rs" target="_blank" rel="noopener" aria-label="Star statsvg_rs on GitHub">
    <span class="repo-star-button-action">
      <svg class="gh-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
      <svg class="star-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" /></svg>
      Star
    </span>
    <span class="repo-star-button-repo">akshay2211/statsvg_rs</span>
  </a>
</aside>]]></content><author><name>Akshay Sharma</name></author><category term="rust" /><category term="github" /><category term="side project" /><category term="svg" /><summary type="html"><![CDATA[Why I built my own GitHub stats card renderer in Rust, how it works, and how it publishes itself as static SVGs every six hours.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ak1.io/img/blog/statsvg-rs.svg" /><media:content medium="image" url="https://ak1.io/img/blog/statsvg-rs.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Shipping Small, Useful Improvements</title><link href="https://ak1.io/blog/2026/06/21/shipping-small-useful-improvements/" rel="alternate" type="text/html" title="Shipping Small, Useful Improvements" /><published>2026-06-21T00:00:00+00:00</published><updated>2026-06-21T00:00:00+00:00</updated><id>https://ak1.io/blog/2026/06/21/shipping-small-useful-improvements</id><content type="html" xml:base="https://ak1.io/blog/2026/06/21/shipping-small-useful-improvements/"><![CDATA[<p>Good software work is often less about making a large dramatic change and more about choosing the smallest improvement that makes the product clearer, faster, or more reliable.</p>

<p>That kind of change is easier to review, easier to test, and easier to explain. It also keeps momentum healthy because every release has a visible reason to exist.</p>

<h2 id="start-with-the-user-path">Start with the user path</h2>

<p>Before touching implementation details, I like to ask one simple question: what should become easier after this change?</p>

<p>If the answer is clear, the scope usually becomes clear too. A useful improvement should remove friction from a real path rather than add surface area just because the system can support it.</p>

<h2 id="keep-the-code-honest">Keep the code honest</h2>

<p>Small changes still deserve care. The implementation should match the existing shape of the codebase, avoid surprising abstractions, and leave the next change easier than this one.</p>

<p>When the code and the user path point in the same direction, shipping becomes less noisy.</p>]]></content><author><name>Akshay Sharma</name></author><category term="software craft" /><category term="product" /><category term="shipping" /><category term="architecture" /><summary type="html"><![CDATA[A short note on keeping product work focused, practical, and easy to validate.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ak1.io/img/blog/shipping-small-useful-improvements.svg" /><media:content medium="image" url="https://ak1.io/img/blog/shipping-small-useful-improvements.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>