<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Muthukumaran Navaneethakrishnan on Medium]]></title>
        <description><![CDATA[Stories by Muthukumaran Navaneethakrishnan on Medium]]></description>
        <link>https://medium.com/@muthuishere?source=rss-32453dd5d721------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*lGg_ExjXieKjM2gYDDF7CQ.jpeg</url>
            <title>Stories by Muthukumaran Navaneethakrishnan on Medium</title>
            <link>https://medium.com/@muthuishere?source=rss-32453dd5d721------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 18:03:33 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@muthuishere/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[I Built a Clojure That Runs on Go(cljgo) . Then I Made It Run a Real AI Agent.]]></title>
            <link>https://muthuishere.medium.com/i-built-a-clojure-that-runs-on-go-cljgo-then-i-made-it-run-a-real-ai-agent-594391ddf00e?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/594391ddf00e</guid>
            <category><![CDATA[clojure]]></category>
            <category><![CDATA[claude-agent-skill]]></category>
            <category><![CDATA[agent-builder]]></category>
            <category><![CDATA[mcp-host]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sun, 02 Aug 2026 18:40:33 GMT</pubDate>
            <atom:updated>2026-08-02T18:40:33.865Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A0gqCVIin7zxUKjJT1bh-w.png" /></figure><p><em>How dogfooding </em><a href="https://muthuishere.github.io/cljgo/why/"><em>cljgo </em></a><em>with a seven-language agent library turned into a Clojure port with zero reader conditionals — and a portability seam called </em><a href="https://github.com/muthuishere/koine"><em>koine</em></a><em> that keeps the ugly parts somewhere else.</em></p><p>Building a language host(cljgo) is easy to fool yourself about. It runs your test suite, it runs your toy programs, and you conclude it works. So I went looking for something big enough to actually hurt.</p><p>I already had one: <a href="https://github.com/muthuishere/toolnexus"><strong>toolnexus</strong></a>, a library I maintain that gives any LLM real tool-calling — MCP servers, agent skills, your own functions, sub-agents — already ported across six languages (JavaScript, Python, Go, Java, C#, Elixir), all pinned to one behavioral contract. Porting it to Clojure would give Clojure developers something they didn’t have. Running that same port on cljgo would tell me whether cljgo is real.</p><h3>The rule that made it interesting: no reader conditionals</h3><p>The obvious way to write code for two hosts is #?(:clj ... :cljgo ...) sprinkled wherever they differ. I refused to do that, for a selfish reason and a principled one.</p><p>The selfish one: every conditional is a place where the two hosts can silently drift apart, and drift is precisely the bug this project exists to prevent.</p><p>The principled one: <strong>toolnexus should not know cljgo exists.</strong> A library about LLM tool-calling has no business containing opinions about which Clojure you run. If it does, then every future host — Babashka, ClojureCLR, whatever comes next — means editing the library.</p><p>So the host differences went somewhere else: <a href="https://github.com/muthuishere/koine"><strong>koine</strong></a>, a small portability seam. File I/O, subprocesses, HTTP, JSON, environment, time, string handling above the BMP — anything the two hosts disagree about lives there and nowhere else. koine is where the conditionals go; toolnexus stays pure Clojure.</p><p>The result is one source tree with <strong>zero reader conditionals</strong>, verified in five execution modes — JVM main, JVM REPL, cljgo AOT, cljgo interpreted, cljgo REPL — with a CI gate that fails if the five disagree by a single assertion. Currently 395 tests, 1,614 assertions, five identical verdicts.</p><p>That means the whole dependency story for a Clojure developer is one line:</p><pre>;; deps.edn — koine comes along with it<br>{:deps {net.clojars.muthuishere/toolnexus {:mvn/version &quot;0.13.0&quot;}}}</pre><p>Any Clojure developer can use this and never think about cljgo. And the one cljgo developer — me, so far — gets the same library, unmodified. That’s the arrangement I wanted.</p><h3>So what is toolnexus?</h3><p>It is not a framework, and there is no runtime to deploy. It is a library built on one observation:</p><blockquote><em>An MCP server tool, an agent skill, a function you wrote, an HTTP endpoint, a shell command, and a remote agent are </em><strong><em>the same thing to an LLM</em></strong><em> — a named, described, schema’d callable.</em></blockquote><p>toolnexus unifies all of those behind one Tool interface, emits the schema in OpenAI / Anthropic / Gemini formats, and ships a client with the tool-calling loop already written: parallel and chained calls, hooks, retries, conversation memory, human-in-the-loop suspension, observability.</p><p>Concretely, you get:</p><ul><li><strong>an MCP host</strong> — point it at an mcp.json and every server&#39;s tools (stdio <em>and</em> streamable-HTTP) join your registry</li><li><strong>agent skills</strong> — the open <a href="https://github.com/anthropics/skills">SKILL.md</a> standard, loaded on demand rather than stuffed into the prompt</li><li><strong>your own functions</strong>, HTTP endpoints, and ten built-in tools (bash, read, write, edit, grep, glob…)</li><li><strong>sub-agents</strong> with scoped tool views and hard budgets, and <strong>remote A2A agents</strong></li></ul><p>All seven ports run the same examples/ fixtures and must produce the same behavior. The Clojure port is held to the same full conformance tier as the other six — it is not the junior member.</p><p>Enough architecture. Here is the thing running.</p><h3>One agent, three tool sources, your terminal</h3><p>Let’s build the thing every framework promises and rarely shows: an interactive agent you keep asking questions, backed by <strong>three different tool sources at once</strong> —</p><ul><li>an <strong>HTTP tool</strong> — one declaration that fetches the official Clojure events page</li><li>an <strong>MCP server</strong> — @modelcontextprotocol/server-filesystem, spawned over stdio; all fourteen of its tools join the registry automatically</li><li>an <strong>agent skill</strong> — a SKILL.md telling the model <em>how</em> to report events, loaded on demand</li></ul><p>(Skills are the open SKILL.md standard — markdown instructions the model loads when the task matches. The catalog goes into the system prompt; the full text loads only on request. Progressive disclosure, not prompt stuffing.)</p><p>The skill — skills/clojure-events/SKILL.md:</p><pre>---<br>name: clojure-events<br>description: Use when asked about Clojure community events, meetups or conferences. How to fetch and report them.<br>---<br># How to report Clojure events</pre><pre>1. Fetch the official events page with your `clojure-events-page` tool — never<br>   answer from memory, events go stale.<br>2. List each event you find as: **name** — date — location (or &quot;online&quot;).<br>3. Only report events that are actually on the page. If the page lists none,<br>   say so.<br>4. Close with the source link: <a href="https://clojure.org/community/events">https://clojure.org/community/events</a><br>5. Keep it a plain list, no marketing tone.</pre><p>The program — src/chat.cljc, the whole thing:</p><pre>(ns chat<br>  (:require [clojure.string :as str]<br>            [koine.env :as env]<br>            [koine.json :as json]<br>            [toolnexus.core :as tn]<br>            [toolnexus.client :as client]<br>            [toolnexus.http :as http]))</pre><pre>(def events-page<br>  (http/http-tool<br>   {:name        &quot;clojure-events-page&quot;<br>    :description &quot;Fetches the official Clojure community events page and returns it as text.&quot;<br>    :method      :get<br>    :url         &quot;https://clojure.org/community/events&quot;<br>    :result-mode &quot;text&quot;}))</pre><pre>(def mcp-config<br>  {:mcpServers<br>   {:files {:type    &quot;local&quot;<br>            :command [&quot;npx&quot; &quot;-y&quot; &quot;@modelcontextprotocol/server-filesystem&quot; &quot;.&quot;]<br>            :timeout 30000}}})</pre><pre>(defn -main [&amp; _]<br>  (let [tk (tn/build {:skills   &quot;skills&quot;<br>                      :builtins false<br>                      :mcp      mcp-config<br>                      :tools    [events-page]})<br>        c  (client/create-client<br>            {:base-url &quot;https://openrouter.ai/api/v1&quot;<br>             :style    &quot;openai&quot;<br>             :model    (or (env/get-env &quot;TN_MODEL&quot;) &quot;openai/gpt-4o-mini&quot;)<br>             :api-key  (env/get-env &quot;OPENROUTER_API_KEY&quot;)})]<br>    (loop []<br>      (print &quot;you&gt; &quot;) (flush)<br>      (let [line (read-line)]<br>        (cond<br>          (or (nil? line) (#{&quot;exit&quot; &quot;quit&quot;} (str/trim line)))<br>          (do (println &quot;bye.&quot;) (tn/shutdown! tk)) <br>           (str/blank? line) (recur)<br>          :else<br>          (let [r (client/ask c line {:toolkit tk :id &quot;cli&quot; :on-event trace})]<br>            (println (str &quot;agent&gt; &quot; (:text r)))<br>            (recur)))))))<br></pre><p>Note client/ask with an :id — that is conversation memory. Every question you type continues the same conversation, so follow-ups like &quot;which of those is soonest?&quot; just work.</p><p>Run it:</p><pre>$ cd clojure/examples &amp;&amp; task clj-ex1</pre><p>It greets you with the full inventory — one HTTP tool, fourteen files_* MCP tools, the skill loader, the clojure-events skill — suggests a few questions, and waits at you&gt;.</p><p><em>Recorded with vhs, unedited.</em></p><h3>The same file on cljgo — no build step</h3><pre>$ task cljgo-ex1</pre><p>That runs the <strong>same </strong><strong>chat.cljc</strong> through cljgo run — Clojure hosted on Go, interpreted directly, no JVM anywhere. The startup banner is worth reading once: cljgo prints net.clojars.muthuishere/toolnexus 0.13.0 — 16 namespace(s) with no Java interop and notes it <em>pruned</em> org.clojure/clojure (&quot;cljgo IS the Clojure implementation&quot;) — that is the static no-interop check happening for real, on the same artifact from Clojars. If you want the 20 ms cold start, cljgo build will also AOT it to a self-contained native binary — but nothing here requires it.</p><p>A real session on cljgo, unedited:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yNguKxPmyOlp4mjkAekhtw.gif" /></figure><p>Both projects ship in the repo under <a href="https://github.com/muthuishere/toolnexus/tree/main/clojure/examples">clojure/examples</a> (clj-ex1 and cljgo-ex1) with the Taskfile; MODEL= overrides the OpenRouter model per run.</p><h3>The parts the example didn’t show</h3><p>The demo used three tool sources. The rest of the library is there too, in all seven ports, because parity is the product:</p><ul><li><strong>Sub-agents</strong> — an agent <em>is</em> a tool: a registry of agents with scoped toolkit views, teams, and hierarchical budgets (:max-tokens, :max-turns, :max-wall-ms…) that stop loudly, never silently</li><li><strong>Personas</strong> — from-dir composes an identity from files (SOUL.md, MEMORY.md…); a memory tool lets an agent edit its own durable notes; start-agent gives it a heartbeat</li><li><strong>Human in the loop</strong> — a tool can suspend with a question; the run comes back pending and resumes when your host answers. MCP servers that elicit input mid-call are bridged onto the same mechanism</li><li><strong>Compaction</strong> — a :before-llm hook that keeps a long-lived agent inside its context window</li><li><strong>Remote agents</strong> — expose your toolkit as an A2A agent with serve, or consume someone else&#39;s; either way it is just more tools</li><li><strong>Built-in tools</strong> — bash, read, write, edit, grep, glob and friends, with per-tool toggles</li></ul><h3>The honest numbers</h3><p>We benchmarked all seven ports plus a dozen competitor frameworks on one machine in one sitting, and published the table as measured. The Clojure port is currently the <strong>slowest port over MCP</strong> — ~5.5 ms p50 per request against Go’s 0.49 — and we know exactly why: ~2 ms per stdio round-trip in the JSON-RPC path, already logged as the top optimization target. With native tools it runs the same scenario in 1.7 ms, mid-table, ahead of LangChain.js and Mastra. And the number I actually care about: <strong>the two hosts agree to within 0.2 ms from the same source file.</strong> The parity claim survives being measured under load.</p><p>If a library only tells you its winning numbers, it’s an ad. Full table: <a href="https://muthuishere.github.io/toolnexus/performance/">performance page</a>.</p><h3>Three gotchas so you don’t find them the hard way</h3><ol><li><strong>The API-key env chain prefers </strong><strong>OPENAI_API_KEY</strong> for openai-style clients. If that&#39;s set and you&#39;re calling OpenRouter, pass :api-key explicitly (as the demo does) — otherwise your OpenAI key goes to OpenRouter and you get a confusing 401.</li><li><strong>cljgo run evaluates top-level forms and doesn&#39;t call </strong><strong>-main.</strong> Add a two-line entry file that requires the ns and calls it — cljgo-ex1&#39;s src/run_chat.cljc is exactly that — or build a binary.</li><li><strong>Write non-ASCII characters directly in source, not as </strong><strong>\uXXXX surrogate escapes</strong> — the JVM reader assembles the pair, cljgo&#39;s doesn&#39;t. (Yes, we found that one the interesting way.)</li></ol><h3>Try it, break it, tell me</h3><p>If you are a Clojure developer, you can use toolnexus today on the JVM and never think about any of the above:</p><pre>{:deps {net.clojars.muthuishere/toolnexus {:mvn/version &quot;0.13.0&quot;}}}</pre><p>If you are curious whether Clojure-on-Go is real, clone the examples and run task cljgo-ex1. That is the same question I was asking, and running it is a better answer than anything I can write here.</p><ul><li><strong>toolnexus</strong> — <a href="https://github.com/muthuishere/toolnexus">repo</a> · <a href="https://muthuishere.github.io/toolnexus/">docs, all seven languages</a> · <a href="https://clojars.org/net.clojars.muthuishere/toolnexus">Clojars</a></li><li><strong>The examples in this article</strong> — <a href="https://github.com/muthuishere/toolnexus/tree/main/clojure/examples">clojure/examples</a></li><li><strong>cljgo</strong> — <a href="https://github.com/muthuishere/cljgo">repo</a> · Clojure hosted on Go</li><li><strong>koine</strong> — <a href="https://github.com/muthuishere/koine">repo</a> · the portability seam</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=594391ddf00e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Don’t build the agent. Build the thing that reads.]]></title>
            <description><![CDATA[<div class="medium-feed-item"><p class="medium-feed-image"><a href="https://muthuishere.medium.com/dont-build-the-agent-build-the-thing-that-reads-9d62cfb4ffd2?source=rss-32453dd5d721------2"><img src="https://cdn-images-1.medium.com/max/1920/1*KSNZBfoRMYS4ZRVBrDHT9A.png" width="1920"></a></p><p class="medium-feed-snippet">What two weeks of measuring small models in a browser tab actually taught me</p><p class="medium-feed-link"><a href="https://muthuishere.medium.com/dont-build-the-agent-build-the-thing-that-reads-9d62cfb4ffd2?source=rss-32453dd5d721------2">Continue reading on Medium »</a></p></div>]]></description>
            <link>https://muthuishere.medium.com/dont-build-the-agent-build-the-thing-that-reads-9d62cfb4ffd2?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/9d62cfb4ffd2</guid>
            <category><![CDATA[browserllm]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sun, 02 Aug 2026 16:12:47 GMT</pubDate>
            <atom:updated>2026-08-02T16:12:47.884Z</atom:updated>
        </item>
        <item>
            <title><![CDATA[Toolnexus: build an agent in a few lines — in any language]]></title>
            <link>https://muthuishere.medium.com/toolnexus-build-an-agent-in-a-few-lines-in-any-language-7037597464e2?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/7037597464e2</guid>
            <category><![CDATA[cross-platform]]></category>
            <category><![CDATA[agent-skills]]></category>
            <category><![CDATA[mcp-client]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Fri, 26 Jun 2026 17:20:34 GMT</pubDate>
            <atom:updated>2026-06-26T17:20:34.442Z</atom:updated>
            <content:encoded><![CDATA[<p><em>One small, vendor-neutral library that gives any LLM dynamic MCP servers, agent skills, your own functions, and HTTP endpoints — as one uniform set of tools. JavaScript, Python, Go, Java, and C#.</em></p><h3>The problem</h3><p>Every LLM project starts the same way. You wire up a couple of <a href="https://modelcontextprotocol.io/">MCP</a> servers. You want “agent skills” — the SKILL.md progressive-disclosure pattern from Claude and opencode, where the model loads an instruction file on demand. You expose a few of your own functions as tools, and maybe a REST endpoint or two. Then you write the tool-calling loop: send the tools, get a tool call, run it, feed the result back, repeat.</p><p>Then you do it again for the next project — in a different language, against a different model provider, with a slightly different glue layer. The frameworks that exist are either single-vendor, single-language, or a whole platform when you wanted a library.</p><h3>The insight</h3><p>To an LLM, an MCP server tool, an agent skill, a native function, and an HTTP endpoint are <em>the same thing</em>: a named, described, schema’d callable. So toolnexus unifies all four behind one Tool interface, aggregates them in a Toolkit, and emits the schema in OpenAI / Anthropic / Gemini shape. A unified client drives any endpoint with them.</p><pre>MCP servers ─┐<br> Agent skills ─┤<br> Native fns  ─┼─▶  one uniform Tool[] registry  ─▶  any LLM (OpenAI / Anthropic / Gemini)<br> HTTP / REST ─┘        + the host loop</pre><h3>Three steps to an agent</h3><ol><li>An mcp.json — your MCP servers (local stdio + remote HTTP, ${ENV} headers for auth).</li><li>A skills/ folder — &lt;name&gt;/SKILL.md files; a skill tool loads them on demand and the catalog is injected into the system prompt.</li><li>One call — agent.run(&quot;…&quot;, { toolkit }). The model already sees every MCP tool + the skill tool and drives them itself.</li></ol><p>Prefer your own loop? Use toOpenAI() / toAnthropic() / toGemini() for the schema and execute(name, args) to run a call.</p><h3>What makes it different</h3><ul><li>Five languages, one behavior. JS/TS, Python, Go, Java, C# — pinned by a shared SPEC.md so they stay byte-identical (the skill-loader output is literally byte-for-byte). First-class Go and C#, where most agent tooling doesn&#39;t reach.</li><li>Built on the real SDKs. Each port uses that ecosystem’s most popular MCP SDK — nothing reinvented.</li><li>A library, not a framework. À la carte: want only an MCP host? Use just the loader. Skills, native/HTTP tools, and the loop are opt-in on top.</li><li>Vendor-neutral. A plain base URL + &quot;openai&quot; or &quot;anthropic&quot; style. No lock-in.</li><li>Batteries verified. Hooks, streaming, retries/backoff, conversation memory, a Go CLI — and every port’s examples run live against OpenRouter.</li></ul><h3>Install</h3><pre>npm i toolnexus                                   # JS / TypeScript<br>pip install toolnexus                             # Python<br>go get github.com/muthuishere/toolnexus/golang    # Go<br>dotnet add package Toolnexus                       # C#  (Maven Central for Java: coming)</pre><h3>Try it</h3><pre>const tk    = await createToolkit({ mcpConfig: &quot;./mcp.json&quot;, skillsDir: &quot;./skills&quot; })<br>const agent = createClient({ baseUrl: &quot;https://openrouter.ai/api/v1&quot;, style: &quot;openai&quot;, model: &quot;openai/gpt-4o-mini&quot; })<br>const { text } = await agent.run(&quot;Refund order 1234 for the customer.&quot;, { toolkit: tk })</pre><p>That’s the whole thing.</p><p>→ <a href="https://github.com/muthuishere/toolnexus">https://github.com/muthuishere/toolnexus</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7037597464e2" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I always loved Hashicorp Vault. But not for this.]]></title>
            <link>https://muthuishere.medium.com/i-always-loved-hashicorp-vault-but-not-for-this-17d230f2a286?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/17d230f2a286</guid>
            <category><![CDATA[bunjs]]></category>
            <category><![CDATA[dotenv]]></category>
            <category><![CDATA[hashicorp-vault]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sat, 16 May 2026 17:47:44 GMT</pubDate>
            <atom:updated>2026-05-16T17:49:26.560Z</atom:updated>
            <content:encoded><![CDATA[<p>I always loved <a href="https://www.vaultproject.io/"><em>HashiCorp Vault</em></a>. The model is right. Dynamic secrets, leasing, identity-based access, real audit. For a serious ops team running real production, Vault is the right answer.</p><p>It’s not the right answer for me, most days.</p><p>What I actually need on a regular evening is much smaller. Look at a dev secret. Maybe change it. Drop a JSON service-account key next to it. Add a regression fixture that mirrors prod shape. Share it with a friend or a teammate. Push it to GitHub Actions and GCP Secret Manager so my VPS deploys aren’t a separate dance.</p><p>That’s the whole job. And every tool I tried either over-solved it, under-solved it, or made me commit something to git I shouldn’t have.</p><p>So I wrote down every specific pain I had. Fixed each one. Called the result <strong><em>vsync</em></strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*uXTjtxPAM5G0XHGc.png" /><figcaption>vsync</figcaption></figure><h3>The pains, in order</h3><h3>1. The .env file is the friendliest file format we have. Don&#39;t make me give it up.</h3><p>Every backend language reads .env either with dotenv or taskfile</p><pre>@Value(&quot;${DATABASE_URL}&quot;) String dbUrl;     // Spring Boot</pre><pre>dbURL := os.Getenv(&quot;DATABASE_URL&quot;)          // Go + godotenv</pre><pre>db_url = os.environ[&quot;DATABASE_URL&quot;]         // Python + python-dotenv</pre><pre>const dbUrl = process.env.DATABASE_URL      // Node + dotenv</pre><p>Most “secret managers” want me to replace dotenv.config() with their SDK call. No thank you. The whole reason .env works is that it&#39;s a one-line bootstrap every language already understands.</p><p>Fix: Vsync uses .env as-is. vsync use dev symlinks ./.env into your vault. Your app keeps calling dotenv.config() with no path argument. Nothing in your bootstrap changes.</p><h3>2. Sharing .env files is awful</h3><p>DM the file in teams. Teammate pastes it. App crashes because their copy is stale. You DM again. Then they need the JSON service-account key too. You attach that to MS Teams. Plaintext. You should delete it later. You don’t.</p><p>Fix: vsync export dev mints a .share file + a passphrase. Send the file on one channel (MS Teams), the passphrase on a different channel (SMS, password manager). Intercept one alone — you have nothing. Teammate runs vsync import dev &lt;file&gt; and vsync pull dev. About thirty seconds, soup to nuts.</p><h3>3. File paths inside .env break on every other laptop</h3><pre>GCP_SA_KEY_FILE_PATH=/Users/muthu/Downloads/gcp-sa.json<br>SSH_KEY_PATH=/Users/muthu/.ssh/id_ed25519</pre><p>That /Users/mrmut/… doesn&#39;t exist on your teammate&#39;s machine. Now they need both the .env and the file it points to. More MS Teams DMs.</p><p>Fix: Vsync ships the whole vault folder, not just the env file. Drop your JSON keys, certs, regression fixtures into infra/vault/&lt;env&gt;/ right next to the env file. Use relative paths inside .env:</p><pre>GCP_SA_KEY_FILE_PATH=./infra/vault/dev/gcp-sa.json<br>SSH_KEY_PATH=./infra/vault/dev/keys/id_ed25519</pre><p>After vsync pull dev, those files are on every teammate&#39;s disk at the same paths. No more &quot;send me the JSON too.&quot;</p><h3>4. Encrypting-in-git is dangerous</h3><p>I lived on <a href="https://github.com/AGWA/git-crypt">git-crypt</a> for a year. Good tool, but:</p><ul><li>The encrypted blob lives in the same repo. Forget the .gitattributes filter on a new file — you commit plaintext. That actually happened to me. Twice.</li><li>Doesn’t play well with git worktree. Keys live in .git/git-crypt/ and don&#39;t follow you to a new worktree. Every fresh worktree was a manual git-crypt unlock before anything worked. Forget that and your app boots with placeholder strings that look like real values. Hardest kind of bug to debug.</li><li>Onboarding a teammate meant adding their GPG key, re-encrypting everything, re-pushing. A five-minute job I always procrastinated.</li></ul><p>Every one of those was “a separate piece of work I had to do.” Individually fine. Together — friction I stopped wanting to pay.</p><p>Fix: Vsync’s encrypted bundle lives on S3, not in your repo. infra/vault/ is gitignored. The plaintext physically cannot enter your application repo by accident — there&#39;s no filter to forget. Worktrees work because the AES key is per-machine in the OS keychain, not per-checkout.</p><h3>5. Manual sync to GitHub Actions and GCP</h3><p>Every VPS deploy used to be: edit the secret in my .env, then re-paste it in GitHub Actions secrets, then update GCP Secret Manager too. Always one of the three drifted.</p><p>Fix: vsync sync dev gh|gcp|all reads .env.&lt;env&gt; and ships every key/value to GitHub Actions / GCP Secret Manager via their CLIs. One vault. Three destinations. One command.</p><p>It even handles the JSON-key case: GCP_SA_KEY_FILE_PATH=./infra/vault/dev/gcp-sa.json in your env file → vsync reads the file at sync time and ships the contents as GCP_SA_KEY to the remote store.</p><h3>6. Zero audit — who saw what, when?</h3><p>With .env + MS Teams, I have no idea who pulled which secrets, from which laptop, on which date. If a prod password leaks tomorrow, I can&#39;t narrow down which teammate&#39;s machine to suspect first.</p><p>Fix: Every push / pull / import / export appends a row to s3://&lt;bucket&gt;/&lt;repo&gt;/&lt;env&gt;/audit.csv. Columns: ts, action, version_ts, hostname, local_ip, os_user, git_email, vsync_version, bun_version, meta.</p><pre>$ vsync audit dev</pre><pre>TS                        ACTION  VERSION          USER     HOST              NOTE<br>------------------------  ------  ---------------  -------  ----------------  ------------------<br>2026-05-16T11:39:49.145Z  pull    20260516-113343  alice    alice-mbp.local   ticket-AUDIT-123<br>2026-05-16T11:33:45.684Z  push    20260516-113343  alice    alice-mbp.local   prod hotfix<br>2026-05-16T08:14:02.211Z  pull    20260515-201410  bob      bob-thinkpad      —</pre><p>Not legal-grade tamper-evidence — anyone with bucket-write could edit the CSV(You can add policies too, but i havent tried !). But enough to answer “who pulled prod last Wednesday at 3 AM?” in one command, which is mostly what I want.</p><h3>7. Existing solutions are paid SaaS or a control plane to run</h3><p><a href="https://www.doppler.com/"><em>Doppler</em></a>, <a href="https://infisical.com/"><em>Infisical</em></a>, <a href="https://dotenvx.com/"><em>dotenvx</em></a> — all good tools. Some have free tiers, some have paid tiers, all of them eventually want you on their cloud. Or you self-host Infisical / Vault and now you’re running a Postgres + Redis + control-plane on top of whatever you’re already running.</p><p>Fix: <strong>Vsync</strong> is MIT. Totally free. No SaaS sign-up. No per-seat pricing. No control plane. BYO S3-compatible bucket — you probably already have one for backups. Works with AWS S3, Hetzner Object Storage, MinIO, Cloudflare R2, Backblaze B2.</p><p>If you outgrow it — Infisical / Doppler / Vault are real upgrade paths. Vsync sits one tier below them on purpose.</p><h3>What it looks like</h3><pre>bun install -g @muthuishere/vsync<br>vsync init dev                 # generate key + config + vault folder<br># drop secrets into infra/vault/dev/<br>vsync push dev                 # AES-256-GCM → your bucket<br>vsync export dev               # → .share file + passphrase for a friend</pre><p>Friend / teammate:</p><pre>vsync import dev ./received.share<br>vsync pull dev<br>vsync use dev                  # ./.env → infra/vault/dev/.env.dev</pre><p>That’s the whole loop.</p><p>The <a href="https://muthuishere.github.io/vsync">docs site</a> covers the rest — the two-halves security model (bucket creds on disk + AES key in OS keychain, both required to decrypt anything), crypto envelopes, manifest pointer-seal, audit append protocol with ETag-conditional retry. If any of that interests you, the docs go deep. If it doesn’t — you can use <em>vsync</em> without ever reading any of it. That was the point.</p><h3>What I gave up</h3><ul><li>Bun ≥ 1.2.21 on PATH. Bun.secrets gives me cross-platform OS keychain access without shipping native modules. If Bun is a dealbreaker — I get it.</li><li>Tamper-evident audit. The CSV is transparency for honest people, not legal forensics.</li><li>No GUI, no daemon, no sidecar. A CLI you run when you need it (or give your agent the docs it can do on its own).</li></ul><h3>Try it</h3><ul><li><a href="https://muthuishere.github.io/vsync">muthuishere.github.io/vsync</a></li><li><a href="https://github.com/muthuishere/vsync">github.com/muthuishere/vsync</a></li><li><a href="https://www.npmjs.com/package/@muthuishere/vsync">npm</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=17d230f2a286" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The 80% Problem — And What LLMs Actually Changed]]></title>
            <link>https://muthuishere.medium.com/the-80-problem-and-what-llms-actually-changed-28b10cffaf8a?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/28b10cffaf8a</guid>
            <category><![CDATA[multi-agent]]></category>
            <category><![CDATA[huddles]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[agent-skills]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sun, 19 Apr 2026 12:47:39 GMT</pubDate>
            <atom:updated>2026-04-19T12:47:39.250Z</atom:updated>
            <content:encoded><![CDATA[<p>Every experienced engineer knows this moment.</p><p>APIs come together. Flows start working. The UI looks complete. Very quickly it feels like the system is 80% done. Everyone relaxes.</p><p>Then the second half begins.</p><p>In 1985, Tom Cargill of Bell Labs gave this pattern its name:</p><blockquote><em>“The first 90% of the code accounts for the first 90% of the development time. The remaining 10% accounts for the other 90% of the development time.”</em></blockquote><blockquote><em>— </em>Communications of the ACM, Programming Pearls (Jon Bentley), 1985</blockquote><p>Software doesn’t get built linearly. It gets built in two phases that feel nothing alike.</p><p>Phase 1 is fast and visible. Features appear. The demo works. Momentum is real.</p><p>Phase 2 is slow and invisible. Edge cases appear. Assumptions break. Real usage arrives. This is where systems either harden or fall apart.</p><h3>What LLMs changed</h3><p>Phase 1 has been compressed. With tools like Claude, you can move from idea to running code in an afternoon. You hit “80% done” faster than was possible a year ago.</p><p>Phase 2 is exactly as hard as it ever was.</p><p>Because the last 20% was never about writing code. It’s about uncovering hidden assumptions, identifying failure paths, understanding real usage, and validating decisions under pressure.</p><p>Before LLMs, the bottleneck was <em>“can we build this?”</em> Now the bottleneck is <em>“did we think about this deeply enough?”</em></p><h3>The scarce resource is questions</h3><p>Answers are easy now. Anyone can generate code, architecture, or docs on demand. What’s become rare is the ability to ask the right questions — the uncomfortable ones:</p><ul><li>What happens when this fails in production?</li><li>Who controls this input?</li><li>What assumption are we making here?</li><li>Is this solving the real problem, or a version of it?</li></ul><p>These questions decide whether a system survives first contact with users.</p><h3>But decisions don’t stay in one room</h3><p>A decision gets made. Then it moves.</p><p>A developer explains it to another developer. Then to a manager. The CTO explains it to the CEO. Each retelling strips something — the tradeoff, the edge case, the assumption nobody wrote down.</p><p>The intent gets diluted. The risks get softened. The wrong thing starts sounding right.</p><p>LLMs give a clean answer — framed in one voice.</p><p>The question isn’t just <em>“what’s the right answer?”</em> It’s <em>“can this decision survive every room it enters?”</em></p><p>Good systems don’t only fail because of bad code. They fail because the thinking didn’t survive translation.</p><h3>They don’t come from one voice</h3><p>A backend engineer sees failure modes. A security engineer sees blast radius. A product manager sees the value metric. An architect sees capacity math. A tech writer makes the decision legible to the next team. A storyteller frames it for the room above engineering.</p><p>Each asks different questions — and most of those questions arrive too late, in incident channels and retros.</p><p>Huddle exists to pull those questions to the front, in every voice the decision will eventually need to survive.</p><p>It runs repo-aware, multi-persona engineering discussions <em>before</em> the system is locked in. 21 named personas — Shaama (backend, rollback-first), Senthil (security, blast radius), Prabagar (PM, value metric), Suren (architect, DORA and boring tech), Deepak (tech writer, handoff-survivable docs), Kishore (storyteller, kills bullet points), and more — disagree with each other, grounded in your repo, then stop and wait for your call.</p><p>Install:</p><pre>npx skills add muthuishere-agent-skills/huddle</pre><p>Better systems aren’t built by faster answers. They’re built by better questions, asked at the right time — and answers that survive every room they enter.</p><blockquote><em>Huddle helps you ask the right questions — and makes sure the answers survive every room they enter.</em></blockquote><p><a href="https://github.com/muthuishere-agent-skills/huddle/">https://github.com/muthuishere-agent-skills/huddle/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=28b10cffaf8a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ Offline LLM Knowledge System — Your Docs. Their Browser. Zero Internet.]]></title>
            <link>https://muthuishere.medium.com/offline-llm-knowledge-system-your-docs-their-browser-zero-internet-ded644e94b83?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/ded644e94b83</guid>
            <category><![CDATA[browsers]]></category>
            <category><![CDATA[no-internet-connection]]></category>
            <category><![CDATA[local-llm]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sun, 05 Apr 2026 22:55:13 GMT</pubDate>
            <atom:updated>2026-04-05T22:55:13.295Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>No API keys. No servers. No internet. Just a zip file.</em></blockquote><p><strong>Status:</strong> Open Source · <a href="https://github.com/muthuishere/offline-llm-knowledge-system">github.com/muthuishere/offline-llm-knowledge-system</a></p><h3>The Problem</h3><p>You’re deploying software to a factory floor. An aircraft carrier. A hospital network. A customer site behind a firewall.</p><p>Your team needs an AI assistant that knows your product. Your runbooks. Your debug commands. But there’s no internet.</p><p>You can’t call OpenAI. You can’t hit Anthropic. You can’t even reach HuggingFace.</p><p><strong>You have a browser. That’s it.</strong></p><p>This isn’t niche. Every enterprise, every OT network, every air-gapped system hits this wall.</p><h3>What I Built</h3><p>Two web applications. Both run in the browser. Zero backend.</p><p><strong>App 1: Export</strong> (needs internet once)</p><ul><li>Drop your documents</li><li>Choose your LLM (Phi-3-mini, Gemma-2B, or TinyLlama)</li><li>Model downloads from HuggingFace</li><li>Documents get chunked and embedded</li><li>Click Export → one zip file (500MB–2.4GB)</li></ul><p><strong>App 2: Import</strong> (never needs internet)</p><ul><li>Drop the zip</li><li>Chat immediately</li><li>All answers from your docs</li></ul><p>No Python. No Node. No Docker. Just modern browsers.</p><p>One zip. One import. Chat works — forever offline.</p><h3>The Use Cases</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/765/1*k45igjb-QXBE_jtBUiML-g.png" /></figure><h3>Why Browsers?</h3><p>Because a browser is already there. Always No installer. No admin rights. No dependency hell. No pip install. No PATH drama.</p><p>A tiny model runs entirely on CPU in WebAssembly. No GPU needed.</p><p>The entire stack runs inside one browser tab.</p><h3>What’s in the zip</h3><pre>knowledge-base.zip<br>├── manifest.json           ← model config, chunk settings, source list<br>├── chunks.json             ← all document chunks with pre-computed vectors<br>├── orama-index.json.gz     ← BM25 full-text search index (offline)<br>├── instructions.md         ← your system prompt<br>├── sources/                ← original documents<br>├── model/                  ← LLM weights (Phi-3-mini, Gemma-2B, or TinyLlama)<br>└── embed-cache/            ← embedding model (bge-small-en-v1.5, pre-bundled)</pre><p>Everything the browser needs. Nothing more.</p><h3>The search pipeline</h3><p>When you ask a question:</p><ol><li>Your query → 384-dimensional vector (ONNX worker, off main thread)</li><li>Hybrid search: BM25 text + cosine similarity → top 3 chunks</li><li>Chunks injected into the <strong>user</strong> message (not system) → model grounds on them</li><li>LLM generates answer in a Web Worker → UI stays responsive</li><li>Answer streams back token by token</li></ol><p>Runs entirely on CPU. Works anywhere.</p><h3>Why context goes in the user turn, not system</h3><p>Small models are trained on instruction-following. They attend much better to content in the <strong>user</strong> turn than buried in the system prompt.</p><p>Classic RAG mistake: stuff retrieved chunks into the system message. Models treat them as background noise, not grounding facts. Answers get vague. Hallucinations creep in.</p><p><strong>Gemma-2B gives the best grounding accuracy</strong> among the three models I tested (Phi-3-mini, Gemma-2B, TinyLlama).</p><p>Correct shape for small models:</p><pre>System: (tiny, stable, &lt;200 chars)<br>  &quot;You are a grounded assistant. Answer only from context.<br>   If unsure, say so. Cite sources. Complete sentences.&quot;</pre><pre>User:<br>  Context:<br>  [DOC 1 — maintenance-guide.md]<br>  ... chunk text ...</pre><pre>  [DOC 2 — error-codes.md]<br>  ... chunk text ...</pre><pre>  Question: Why does error E-4417 appear after restart?</pre><p>This shape gets 3–5× better grounding on smaller models than standard RAG prompts.</p><h3>Why tiny chunks</h3><p>Standard RAG uses 512-token chunks. For a 4K-context model running 3-chunk retrieval, that’s 1,536 tokens before you even add the question. Small models have no room left for output.</p><p>I use 100-token chunks (~400 chars).</p><p>Three chunks = 400 tokens. Plenty of room. More focused retrieval. Better answers.</p><h3>The Stack</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/979/1*74uvNLFhJmk3WOyBvmdy4g.png" /><figcaption>No Node.js at runtime. No Python. No Docker. No cloud.</figcaption></figure><h3>The embed model bundling trick</h3><p>At export time, after embedding runs, I drain the browser’s transformers-cache Cache API — where transformers.js stores model files from HuggingFace.</p><p>I capture only the model files (not CDN runtime), bundle them as embed-cache/N.bin with a URL index.</p><p>At import time, the unzip worker restores them into cache with original URLs as keys.</p><p>When the embed worker starts pipeline(), transformers.js finds files in cache. Zero network requests.</p><p>Fully offline after one export.</p><h3>What This Unlocks</h3><ul><li><strong>No-internet deployments</strong>: factory floors, ships, aircraft, hospitals, government networks</li><li><strong>Data sovereignty</strong>: your docs never leave your machine. Ever.</li><li><strong>Zero infrastructure</strong>: no vector DB, no GPU server, no API keys</li><li><strong>Portability</strong>: one zip, any browser, any OS</li><li><strong>Customizable</strong>: swap the LLM, change the prompt, add docs — just re-export</li><li><strong>Debuggable</strong>: kb.ask(&quot;question&quot;), kb.search(&quot;question&quot;), kb.prompt(&quot;question&quot;) in the console</li></ul><h3>The Console API</h3><p>This tool is for technical environments. So there’s a full console API for testing without the UI:</p><pre>// Ask a question (embed + search + LLM)<br>await kb.ask(&quot;Why does error E-4417 appear after restart?&quot;)</pre><pre>// Just show what documents were retrieved<br>await kb.search(&quot;network timeout configuration&quot;)</pre><pre>// Show the full prompt being sent to the LLM (debug)<br>await kb.prompt(&quot;How do I restart the monitoring service?&quot;)</pre><pre>// Show conversation history<br>kb.history()</pre><pre>// Batch test multiple questions<br>await kb.batch([<br>  &quot;What is the default retry limit?&quot;,<br>  &quot;How do I clear the event log?&quot;,<br>  &quot;What ports need to be open?&quot;<br>])</pre><p>Drop this in your debug session. Keep it in your ops runbook. Test your knowledge base before shipping the zip to the field.</p><h3>Why Now</h3><p>The models got small enough.</p><p>TinyLlama: 550MB. Gemma-2B: 1.4GB. Phi-3-mini: 1.3GB.</p><p>The tooling matured: WebLLM, transformers.js, Orama.</p><p>WebAssembly made CPU inference practical.</p><p>Gemma-2B<strong> delivers the best accuracy</strong> while staying under 1.6GB.</p><p>The gap between “LLM works on your laptop” and “LLM works with your data in your browser” closed.</p><p>It’s now 15 lines of config and a zip file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*BSyV5tZs47uZhXm-PZ2QTg.gif" /><figcaption>Demo</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ded644e94b83" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hand Drawn Diagram — Agentic Skill]]></title>
            <link>https://muthuishere.medium.com/hand-drawn-diagram-agentic-skill-48f449642ad4?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/48f449642ad4</guid>
            <category><![CDATA[skills]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[agentic-skills]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Sat, 21 Mar 2026 17:43:51 GMT</pubDate>
            <atom:updated>2026-03-21T17:43:51.877Z</atom:updated>
            <content:encoded><![CDATA[<p>I like hand-drawn diagrams. I like animation. And I love Excalidraw. But I hate starting from a blank canvas. Every time I want to explain something, I end up spending 10–15 minutes drawing boxes and arrows before I even start thinking properly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/720/1*v4hB8u_p8l2ja1cVDRdpsw.gif" /></figure><p>I tried existing Excalidraw MCP/Skill tools, but they didn’t work for me. Too rigid, too polished, no sketch feel, no animation, and they don’t fit into my coding flow. I don’t want a “perfect diagram generator” — I just want a fast starting point.</p><p>So I built a small skill: I describe what I want, and it generates a hand-drawn diagram(+ an animated version), opens in my hosted Excalidraw editor, and even gives me an animation view. From there, I just drag, tweak, and refine like I normally would.</p><p><a href="https://github.com/muthuishere/hand-drawn-diagrams">GitHub - muthuishere/hand-drawn-diagrams: Hand-drawn diagram skill for Claude Code and Codex. Generates monochrome architecture, workflow, and UX blueprint diagrams as PNGs.</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=48f449642ad4" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Securing a Production VPS in Practice]]></title>
            <link>https://muthuishere.medium.com/securing-a-production-vps-in-practice-e3feaa9545af?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/e3feaa9545af</guid>
            <category><![CDATA[vps]]></category>
            <category><![CDATA[crowdsec]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Thu, 01 Jan 2026 19:25:48 GMT</pubDate>
            <atom:updated>2026-01-01T19:25:48.160Z</atom:updated>
            <content:encoded><![CDATA[<h3>Let’s start with a simple assumption.</h3><p>My VPS is running production services. Docker containers. A reverse proxy. SSH access.</p><p>This is a very common setup.</p><p>For a long time, I believed it was secure enough. SSH uses keys. Only required ports are open. Containers isolate services.</p><p>That belief was comfortable.</p><p>Then I looked at the logs.</p><pre>cat  /var/log/auth.log | grep &quot;invalid user&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wUKHsczsnVmoAZVvDs63_g.png" /><figcaption>Multiple Attempts</figcaption></figure><h3>What the Logs Tell You</h3><p>They showed repeated failed login attempts. Not random. Automated. Continuous.</p><p>None of them succeeded.</p><p>That is not the interesting part.</p><p>The interesting part is this: they were happening all the time, and I was not watching.</p><p>Now HTTP logs</p><pre>grep -E &#39;(/\.env\b|\.env\b|/wp-admin\b|/phpMyAdmin\b|/\.git/config\b|aws(_)?credentials\b|/\.aws/credentials\b)&#39; \<br>$(docker inspect kamal-proxy --format &#39;{{.LogPath}}&#39;)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q5y9dUP-mizLbIyXzugIiQ.png" /><figcaption>multiple http attempts to get secret files</figcaption></figure><p>The reverse proxy was receiving requests for:</p><ul><li>/.env</li><li>/.aws/credentials</li><li>/phpMyAdmin</li><li>.git/config</li><li>cloud credential paths</li></ul><p>Most returned 404. Some returned 403.</p><p>Again, nothing broke.</p><p>But this raises an important question.</p><h3>What If You Forgot Something?</h3><p>Security is not about what you know. It is about what you do not know.</p><p>You might have:</p><ul><li>a debug endpoint</li><li>a framework default</li><li>a misconfigured path</li><li>a route exposed only with a certain query string</li></ul><p>You may never notice it.</p><p>Attackers will try to find it.</p><p>This is the problem CrowdSec is designed to address.</p><h3>What CrowdSec Actually Does</h3><p>CrowdSec does not block requests directly.</p><p>It watches behavior.</p><p>The process is simple.</p><ol><li>Read logs</li><li>Parse them into fields</li><li>Observe patterns over time</li><li>Take action ( Obviously ban it)</li></ol><p>It does not react to a single request. It reacts to repeated behavior.</p><p>This distinction matters.</p><h3>Why Bans Are Temporary</h3><p>A common question is: “Is the ban permanent?”</p><p><em>No.</em></p><p>You can see this directly:</p><pre>cscli decisions list</pre><p>Every decision has an expiration time.</p><p>This is intentional.</p><p>Permanent bans cause problems:</p><ul><li>IPs are often shared</li><li>false positives exist</li><li>firewall rules accumulate silently</li></ul><p>CrowdSec assumes behavior can change.</p><p>If the behavior repeats, the ban repeats. If it stops, the ban expires.</p><p>This keeps the system safe and reversible.</p><h3>SSH Protection</h3><p>SSH is the easiest case.</p><p>CrowdSec already understands SSH logs.</p><p>It detects:</p><ul><li>repeated authentication failures</li><li>brute-force patterns</li><li>user enumeration attempts</li></ul><p>Once a threshold is crossed, the firewall blocks the IP.</p><p>Nothing custom is required.</p><p>In practice, SSH attempts stop quickly after detection.</p><h3>HTTP Protection</h3><p>HTTP is different.</p><p>Attackers do not repeat one path. They probe many paths.</p><p>This is why behavior matters.</p><p>My reverse proxy runs inside Docker. CrowdSec reads container logs directly.</p><p>That part is straightforward.</p><h3>Parsers and Log Formats</h3><p>CrowdSec ships with parsers for common software:</p><ul><li>Nginx</li><li>Apache</li><li>SSH</li></ul><p>If you use these, nothing more is needed.</p><p>My proxy logs are JSON and slightly different.</p><p>So I added a small parser.</p><p>It extracts:</p><ul><li>source IP</li><li>request path</li><li>method</li><li>status code</li></ul><p>That is enough.</p><p>CrowdSec scenarios already know how to use these fields.</p><p>If you run something like Spring Boot with custom logs, the same rule applies. You write a small parser(just few config files). You do not rewrite detection logic.</p><h3>Docker, Parsers, and Enrichers</h3><p>Think of it in layers.</p><ol><li>Raw logs from Docker</li><li>Parser extracts fields</li><li>Enricher adds context</li></ol><p>This structure keeps things simple.</p><p>No magic. Just structured information.</p><h3>Detecting HTTP Attacks</h3><p>CrowdSec includes community-maintained HTTP scenarios.</p><p>They look for:</p><ul><li>multiple sensitive paths</li><li>short time windows</li><li>systematic probing</li></ul><p>One request is noise. Repeated patterns are intent.</p><p>That is when a ban is issued.</p><h3>Verifying Behavior</h3><p>You do not guess.</p><p>You check.</p><p>Active decisions:</p><pre>cscli decisions list</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ox-at_jNs34TweRuvZkHyQ.png" /></figure><p>Available HTTP scenarios:</p><pre>cscli hub list | grep http</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sC1FR2pb9Mc7qZOF7dQtEQ.png" /></figure><p>Inspect why something triggered:</p><pre>cscli scenarios inspect &lt;scenario-name&gt;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r4s1wz-QeIeNuXjj4Un1nA.png" /></figure><p>Everything is observable.</p><h3>Notifications</h3><p>Blocking silently is not enough.</p><p>I send notifications to Telegram.</p><p>Each ban tells me:</p><ul><li>which IP</li><li>why it was banned</li><li>how long it lasts</li></ul><p>This provides visibility without noise</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mCLR_hUtPr9r31PN9fIZrg.png" /></figure><h3>Automation</h3><p>You <em>can</em> install CrowdSec manually and configure everything by hand — Telegram notifications, Kamal proxy logs, parsers, and so on.</p><p>But honestly, that feels odd.</p><p>Most security software makes installation messy. Many steps. Many files. Easy to forget something.</p><p>CrowdSec itself is very good, but I didn’t want a setup that depends on manual work.</p><p>So I created a small repo with one task.</p><p>That one task:</p><ul><li>installs CrowdSec</li><li>installs required scenarios</li><li>adds custom parsers</li><li>configures notifications</li></ul><p>That’s it.</p><p>With this, the setup becomes repeatable. You can apply it to any VPS and get the same result every time.</p><p><a href="https://github.com/muthuishere/automated-crowdsec-kamal">https://github.com/muthuishere/automated-crowdsec-kamal</a></p><h3>This setup will not stop advanced, targeted attacks.</h3><p>But it does stop the common stuff:</p><ul><li>automated SSH brute-force attempts</li><li>scanning for common HTTP targets like .env and AWS credential files</li><li>repeated 404-style probing across many paths</li></ul><p>And honestly, this is most of what hits a public VPS.</p><p>Once this noise is blocked early, the system becomes much quieter and easier to manage.</p><h3>Final Thought</h3><p>Securing a production VPS is not about perfection. It is about paying attention.</p><p>If your server is public, it is being scanned. SSH attempts. HTTP probing. Repeated 404s.</p><p>The question is not whether this happens. The question is whether you are watching it.</p><p>Before adding new tools, look at your logs.</p><p>Check your SSH logs for repeated failures. Check your container or proxy logs for probing patterns and suspicious 404s.</p><p>If you are not doing this, you are flying blind.</p><p>Tools like CrowdSec help because they automate what an operator should already be doing: watch behavior, react early, and avoid permanent mistakes.</p><p>Temporary bans. Clear signals. Simple automation.</p><p>That is often enough to secure a production VPS — and to sleep better while running it.</p><p><a href="https://youtu.be/hSiMfbJ4c0Q">https://youtu.be/hSiMfbJ4c0Q</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e3feaa9545af" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pair Programming, Copilot Agents, and Cost of Not Asking Questions]]></title>
            <link>https://muthuishere.medium.com/pair-programming-copilot-agents-and-cost-of-not-asking-questions-7f3adb27ba69?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/7f3adb27ba69</guid>
            <category><![CDATA[github-copilot]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[pair-programming]]></category>
            <category><![CDATA[product-management]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Tue, 16 Dec 2025 15:13:32 GMT</pubDate>
            <atom:updated>2025-12-16T15:13:32.863Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DtOXehmZ6eNHwb209swVrg.png" /><figcaption>Asking Questions</figcaption></figure><p>For years, I pushed <strong>pair programming</strong>. Not because it is a ritual, but because it is a way of thinking together.</p><p>Most people avoided it. Not because pairing doesn’t work, but because it exposes things. Habits are visible. Gaps are visible. Over time, those were treated as wrong things and quietly tied to something else.</p><p>Once that happens, pairing becomes unsafe. People hesitate unless it is senior–junior. Otherwise, they avoid it.</p><p>Something important was lost there: <strong>asking questions.</strong></p><p>When pairing was healthy, people didn’t trust the first answer. They checked multiple sources. That would have been questioned in pairing or in PRs. There were uncomfortable conversations, and those uncomfortable conversations helped the product.</p><p><strong>Psychological safety mattered.</strong> People felt safe to say, “I don’t know,” or “let us check one more source.” Once safety is gone, questions disappear.</p><p>David J. Malan, the Harvard professor behind CS50, coded live and made mistakes. He didn’t hide them. That made it safe for others to ask questions.</p><p>Now we are in the <strong>Copilot era</strong>.</p><p>Everyone trusts the one-shot answer from LLM. Sometimes it is right. Sometimes it is good. But people don’t ask more questions.</p><p>Is this safe? Will this scale? What happens when we need to change or delete this?</p><p><strong>Asking questions is a soft skill</strong>. That skill was built when people practiced pairing. Because we haven’t been there, this quality is missing across teams.</p><p>I don’t know whether pairing is needed or not. But the soft skill we built there is needed.</p><p>Put people into pairs for some time (no AI agents). Make friction for some time. Then let them work alone (with an AI agent).</p><p>This subtle habit may increase asking the right questions.</p><p>Treat Copilot as a pair, not as an authority. Ask two more questions.</p><p>Most system failures don’t come from bad code. They come from moments where no one asked, <strong>“Are we sure?”</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7f3adb27ba69" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Docker Desktop → Colima + Portainer: My Mac Finally Breathes]]></title>
            <link>https://muthuishere.medium.com/docker-desktop-colima-portainer-my-mac-finally-breathes-b70d82d6cf0f?source=rss-32453dd5d721------2</link>
            <guid isPermaLink="false">https://medium.com/p/b70d82d6cf0f</guid>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[portainer]]></category>
            <category><![CDATA[colima]]></category>
            <dc:creator><![CDATA[Muthukumaran Navaneethakrishnan]]></dc:creator>
            <pubDate>Mon, 06 Oct 2025 15:24:49 GMT</pubDate>
            <atom:updated>2025-10-06T15:25:22.051Z</atom:updated>
            <content:encoded><![CDATA[<p>I always thought Docker Desktop was <em>good despite being heavy</em>. Yes, the CPU spiked, fans went crazy, but I could see containers, browse logs, jump into consoles — so I lived with it.</p><p>Then one day(today), I ran just two Node.js apps and a Python app with docker compose, and my system completely froze. Even the mouse wouldn’t move.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*j7wGOaHDjewvmI0E" /></figure><p>Look at those numbers: Docker at 308%, Virtual Machine Service at 208%. Sure, the VM usage is tied to Docker’s VM config — but why on earth should Docker itself hit 300%?</p><p>That’s when I decided: <em>I can’t keep doing this.</em></p><h3>Looking for Alternatives</h3><p>I knew about Podman and Colima. I had tried Podman before, but my skill issues and conservative mindset. I always went crawling back to Docker Desktop.</p><p>I thought should try colima — without uninstalling Docker Desktop.</p><h3>Installing Colima</h3><pre>brew install colima<br>colima start --cpu 2 --memory 4</pre><p>Colima is light, quiet, and barely touches CPU.</p><p>And the best part? My same docker CLI just works. I only had to switch the context:</p><pre>docker context use colima</pre><p>That’s it. No extra setup.</p><h3>Running My Biggest Compose Setup</h3><p>I ran the same heavy setup again: two Node.js apps and a Python app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*IeuGQJhd8VqZHbX4" /></figure><p>Virtual Machine Service still shows 97% (that’s macOS VM overhead), but Colima itself sits at just 0.2%.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*juaN5sHo-ekrpnS0" /></figure><p>From a system-hanging 308% → to basically nothing. That’s a massive win.</p><h3>Adding Back a UI</h3><p>The one thing I missed was Docker Desktop’s UI — the ability to see containers, images, consoles, networks.</p><p>Then I came across Portainer.</p><pre>docker run -d \<br>  --name portainer \<br>  -p 9000:9000 \<br>  -v /var/run/docker.sock:/var/run/docker.sock \<br>  portainer/portainer-ce:latest</pre><p>Open <a href="http://localhost:9000/">http://localhost:9000</a> and you get a clean, web-based Docker UI.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*HLrwX-_YDjRKloWx" /></figure><p>Now I have everything Docker Desktop gave me , containers , images , consoles — but without the CPU pain.</p><h3>Handy Zsh Aliases</h3><pre># Switch to Colima<br>alias switch-colima=&#39;colima start --cpu 2 --memory 4 &amp;&amp; docker context use colima &amp;&amp; export DOCKER_CONTEXT=colima &amp;&amp; echo &quot;🐳 Colima now&quot;&#39;<br># Switch back to Docker Desktop<br>alias switch-docker=&#39;colima stop &amp;&amp; docker context use desktop-linux &amp;&amp; unset DOCKER_CONTEXT &amp;&amp; echo &quot;🐳 Docker Desktop now&quot;&#39;<br># Quick status<br>alias docker-which=&#39;echo &quot;Context: $(docker context show)&quot;&#39;</pre><h3>Final</h3><p>You don’t have to stick with something just because you’re used to it.<br> There are always better options — you just need to look.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b70d82d6cf0f" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>