<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>Notes — Brahim Bousnguar</title>
  <subtitle>MuleSoft, SAP Commerce, Salesforce integration and AI-augmented engineering.</subtitle>
  <link rel="self" type="application/atom+xml" href="https://heybrahim.com/notes/feed.xml"/>
  <link rel="alternate" type="text/html" href="https://heybrahim.com/notes/"/>
  <id>https://heybrahim.com/notes/</id>
  <updated>2026-09-18T00:00:00Z</updated>
  <author><name>Brahim Bousnguar</name><uri>https://heybrahim.com/</uri></author>
  <entry>
    <title>Counting Claude Code tokens from the logs? You&#x27;re probably 2.5× too high</title>
    <link rel="alternate" type="text/html" href="https://heybrahim.com/notes/claude-code-token-overcount.html"/>
    <id>https://heybrahim.com/notes/claude-code-token-overcount.html</id>
    <published>2026-09-18T00:00:00Z</published>
    <updated>2026-09-18T00:00:00Z</updated>
    <summary>Claude Code writes one log line per part of a reply, and every line repeats the whole reply&#x27;s token usage. Count lines and you overcount tokens about 2.5×, output about 4.4×. Key by message id instead. Here&#x27;s the fix from Meterlex, plus the same trap in Gemini CLI.</summary>
    <category term="Claude Code"/><category term="AI tooling"/><category term="observability"/><category term="Python"/>
    <content type="html">&lt;p&gt;If you add up token usage from Claude Code&#x27;s session logs line by line, your total is wrong. On my machine it was &lt;strong&gt;2.5×&lt;/strong&gt; too high, and output tokens were &lt;strong&gt;4.4×&lt;/strong&gt; too high.&lt;/p&gt;
&lt;p&gt;The logs aren&#x27;t lying. Every line just repeats the usage of the whole reply it belongs to. Count replies, not lines: key by the message id.&lt;/p&gt;
&lt;p&gt;I hit this building &lt;a href=&quot;/projects/meterlex.html&quot;&gt;Meterlex&lt;/a&gt;, a tool that prices my AI coding usage at API rates. Here&#x27;s what&#x27;s going on and the fix.&lt;/p&gt;
&lt;h2 id=&quot;one-reply-many-lines&quot;&gt;One reply, many lines&lt;/h2&gt;
&lt;p&gt;Claude Code keeps transcripts in &lt;code&gt;~/.claude/projects/**/*.jsonl&lt;/code&gt;. A single assistant reply isn&#x27;t one line. Each part of it is logged on its own line: the thinking, the text, each tool call.&lt;/p&gt;
&lt;p&gt;And each of those lines carries the same &lt;code&gt;usage&lt;/code&gt; block, for the whole reply. Simplified, with made-up numbers, one reply with a text part and a tool call looks like this:&lt;/p&gt;
&lt;pre data-lang=&quot;json&quot;&gt;&lt;code&gt;{&quot;type&quot;: &quot;assistant&quot;, &quot;uuid&quot;: &quot;a1…&quot;, &quot;message&quot;: {&quot;id&quot;: &quot;msg_01X…&quot;, &quot;usage&quot;: {&quot;input_tokens&quot;: 12, &quot;output_tokens&quot;: 480}}}
{&quot;type&quot;: &quot;assistant&quot;, &quot;uuid&quot;: &quot;b2…&quot;, &quot;message&quot;: {&quot;id&quot;: &quot;msg_01X…&quot;, &quot;usage&quot;: {&quot;input_tokens&quot;: 12, &quot;output_tokens&quot;: 480}}}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two lines, two different &lt;code&gt;uuid&lt;/code&gt;s, one &lt;code&gt;message.id&lt;/code&gt;. Sum both and you&#x27;ve counted that reply twice. A reply with five tool calls gets counted six or seven times.&lt;/p&gt;
&lt;h2 id=&quot;how-big-the-error-is&quot;&gt;How big the error is&lt;/h2&gt;
&lt;p&gt;On my Mac&#x27;s transcripts: &lt;strong&gt;44,158&lt;/strong&gt; assistant lines, but only &lt;strong&gt;18,701&lt;/strong&gt; actual replies.&lt;/p&gt;
&lt;p&gt;Counting lines gave 2.5× the real tokens. Output tokens were 4.4× too high.&lt;/p&gt;
&lt;p&gt;That matters if you&#x27;re doing what Meterlex does: comparing a flat subscription with what the same usage would cost on the API. Overcount the usage and the subscription looks like a way better deal than it is.&lt;/p&gt;
&lt;h2 id=&quot;the-fix-key-by-message-id&quot;&gt;The fix: key by message id&lt;/h2&gt;
&lt;p&gt;Use &lt;code&gt;message.id&lt;/code&gt; as the key for a reply, and when you see it again, merge instead of adding. The one subtle bit: a reply&#x27;s numbers can grow while it streams, so keep each count at its largest value, not the first or the sum.&lt;/p&gt;
&lt;p&gt;This is the core of it, from the Meterlex collector:&lt;/p&gt;
&lt;pre data-lang=&quot;python&quot;&gt;&lt;code&gt;def _merge_max(a, b):
    &quot;&quot;&quot;Two records of the same reply: each token count at its largest
    (a reply&#x27;s numbers only grow as it streams), the earliest time.&quot;&quot;&quot;
    m = dict(a)
    for f in TOKEN_FIELDS:
        m[f] = max(a[f], b[f])
    m[&quot;ts&quot;] = min(a[&quot;ts&quot;], b[&quot;ts&quot;])
    return m

key = (session_id, msg.get(&quot;id&quot;) or uuid)
replies[key] = _merge_max(replies[key], t) if key in replies else t&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lines without a &lt;code&gt;message.id&lt;/code&gt; fall back to their &lt;code&gt;uuid&lt;/code&gt;, so nothing gets dropped.&lt;/p&gt;
&lt;h2 id=&quot;migrating-the-rows-you-already-stored&quot;&gt;Migrating the rows you already stored&lt;/h2&gt;
&lt;p&gt;If you&#x27;ve been storing per-line rows, re-sending the same transcript with the new key would just add a third copy. So each reply also carries its old per-line &lt;code&gt;uuid&lt;/code&gt;s as &lt;code&gt;alt_keys&lt;/code&gt;, and the hub folds any stored row with one of those keys into the new one. Old data cleans itself up the next time a file is read.&lt;/p&gt;
&lt;h2 id=&quot;gemini-cli-does-it-too&quot;&gt;Gemini CLI does it too&lt;/h2&gt;
&lt;p&gt;Different tool, same trap. Gemini CLI appends a message again every time it updates it. One project&#x27;s file had &lt;strong&gt;56,651&lt;/strong&gt; lines for &lt;strong&gt;908&lt;/strong&gt; messages. Same fix: key by message id.&lt;/p&gt;
&lt;p&gt;Codex has a milder version: it can repeat a &lt;code&gt;token_count&lt;/code&gt; event with an unchanged running total. Meterlex skips those.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;p&gt;If you track AI usage from local logs, check your keys. Count distinct message ids against lines on one of your own transcripts. The gap is the overcount.&lt;/p&gt;
&lt;p&gt;Meterlex does all of this for Claude Code, Codex, Gemini CLI, Antigravity, Ollama, Copilot CLI and OpenClaw, and it&#x27;s MIT on &lt;a href=&quot;https://github.com/brbousnguar/meterlex&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;GitHub&lt;/a&gt;. If your numbers look off in a different way, open an issue.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>The official MuleSoft MCP server can&#x27;t read your logs, so I built one that can</title>
    <link rel="alternate" type="text/html" href="https://heybrahim.com/notes/mulewatch-anypoint-logs-mcp.html"/>
    <id>https://heybrahim.com/notes/mulewatch-anypoint-logs-mcp.html</id>
    <published>2026-09-18T00:00:00Z</published>
    <updated>2026-09-18T00:00:00Z</updated>
    <summary>mulewatch is an open-source, read-only MCP server that lets Claude, Cursor or any MCP client read live and archived MuleSoft Anypoint logs. The trick is finding the right replica in the Monitoring Archive in one call instead of hundreds.</summary>
    <category term="MuleSoft"/><category term="Anypoint Platform"/><category term="MCP"/><category term="observability"/>
    <content type="html">&lt;p&gt;MuleSoft has an official MCP server. It scaffolds projects, generates flows, deploys apps. What it can&#x27;t do is the one thing I need when something breaks in prod: read the logs.&lt;/p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https://github.com/brbousnguar/mulewatch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;mulewatch&lt;/a&gt;. It&#x27;s an open-source MCP server that gives Claude, Cursor or any MCP client your Anypoint logs, live and archived. Read-only. One line to run: &lt;code&gt;npx -y mulewatch&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I first built a version of this for my day job. This one is the cleaned-up, works-for-anyone release.&lt;/p&gt;
&lt;h2 id=&quot;building-vs-running&quot;&gt;Building vs. running&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://docs.mulesoft.com/mulesoft-mcp-server&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;official server&lt;/a&gt; is for building Mule apps, and it&#x27;s good at that. But it expects a local Mule project open in your IDE. When a flow fails in prod, you usually don&#x27;t have one open. You have Runtime Manager in a browser tab.&lt;/p&gt;
&lt;p&gt;mulewatch covers the other half of the job: figuring out what happened. Logs, deployed apps, API Manager instances, Exchange assets. Nothing that writes.&lt;/p&gt;
&lt;h2 id=&quot;the-live-tail-forgets-fast&quot;&gt;The live tail forgets fast&lt;/h2&gt;
&lt;p&gt;The logs you see in Runtime Manager are a live tail: a small rolling buffer per app. On a busy app it scrolls out in minutes, whatever &lt;code&gt;startTime&lt;/code&gt; you pass. If the problem happened last night, it&#x27;s already gone.&lt;/p&gt;
&lt;p&gt;The real history lives in the Anypoint Monitoring Archive API. It stores logs in 10-minute files that land about 10 minutes after each window closes, and keeps them for a long time. You need Anypoint Monitoring enabled on the org.&lt;/p&gt;
&lt;h2 id=&quot;the-fun-part-finding-the-right-replica&quot;&gt;The fun part: finding the right replica&lt;/h2&gt;
&lt;p&gt;Here&#x27;s the catch. The archive isn&#x27;t indexed per app. It&#x27;s indexed per replica, as &lt;code&gt;{appName}_{replicaId}&lt;/code&gt;, and every redeploy creates new replicas. So you can&#x27;t just ask for &quot;the logs of order-sync-api on 9 August&quot;. You need to know which pod was running that day.&lt;/p&gt;
&lt;p&gt;The app I tested on had &lt;strong&gt;669&lt;/strong&gt; replica entities in the archive. The API allows 60 requests a minute. Probing all of them is about 11 minutes of waiting before you read a single line.&lt;/p&gt;
&lt;p&gt;Turns out you almost never need to. Pods usually live until the next redeploy, so the replica running right now probably wrote the recent logs too. mulewatch does this:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;Grab the replica IDs from the live tail and try those first.&lt;/li&gt;&lt;li&gt;No files for that date? The app was redeployed since. Fall back to scanning every replica it ever had, capped by &lt;code&gt;maxEntities&lt;/code&gt;.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;On that test, searching today probed &lt;strong&gt;1&lt;/strong&gt; replica, found 127 archive files and parsed 2,859 log lines. A date from before the last redeploy fell back to the full scan, as it should. The client also stays under the rate limit and backs off on &lt;code&gt;429&lt;/code&gt;, so a long search gets slower instead of failing.&lt;/p&gt;
&lt;h2 id=&quot;read-only-on-purpose&quot;&gt;Read-only, on purpose&lt;/h2&gt;
&lt;p&gt;I don&#x27;t want an LLM restarting production Mule apps. So there&#x27;s no deploy, stop, restart or policy tool in mulewatch. None. If you need those, use the official server next to it.&lt;/p&gt;
&lt;p&gt;You can also lock the whole server to the environments you trust:&lt;/p&gt;
&lt;pre data-lang=&quot;dotenv&quot;&gt;&lt;code&gt;ANYPOINT_ALLOWED_ENVIRONMENTS=Dev,Sandbox&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anything outside the list gets refused, with the reason. Pair it with a connected app that only sees those environments and prod is out of reach twice.&lt;/p&gt;
&lt;h2 id=&quot;the-thing-thatll-trip-you-up&quot;&gt;The thing that&#x27;ll trip you up&lt;/h2&gt;
&lt;p&gt;Environments live on business groups, not on the root org. Connected-app credentials resolve to the root org, which often has zero environments. So your first call comes back empty and you think the credentials are broken. They&#x27;re not.&lt;/p&gt;
&lt;p&gt;Call &lt;code&gt;anypoint_list_business_groups&lt;/code&gt;, grab the right id, set &lt;code&gt;ANYPOINT_ORG_ID&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;Same energy for scopes: Anypoint answers a missing scope with a bare &lt;code&gt;403&lt;/code&gt;. mulewatch catches it and tells you which scope you&#x27;re probably missing.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;p&gt;Create a connected app (&quot;acts on its own behalf&quot;), give it read scopes, and add this to Claude Desktop, Cursor or OpenClaw:&lt;/p&gt;
&lt;pre data-lang=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;mcpServers&quot;: {
    &quot;mulewatch&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;mulewatch&quot;],
      &quot;env&quot;: {
        &quot;ANYPOINT_CLIENT_ID&quot;: &quot;your_connected_app_client_id&quot;,
        &quot;ANYPOINT_CLIENT_SECRET&quot;: &quot;your_connected_app_client_secret&quot;
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or in Claude Code:&lt;/p&gt;
&lt;pre data-lang=&quot;bash&quot;&gt;&lt;code&gt;claude mcp add mulewatch --env ANYPOINT_CLIENT_ID=... --env ANYPOINT_CLIENT_SECRET=... -- npx -y mulewatch&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run &lt;code&gt;anypoint_whoami&lt;/code&gt; first to see what it&#x27;s connected to. Then just ask: &lt;em&gt;&quot;show me the ERROR lines of order-sync-api in Prod yesterday between 1 and 3 a.m.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It&#x27;s v0.1, Apache-2.0, on &lt;a href=&quot;https://www.npmjs.com/package/mulewatch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;npm&lt;/a&gt; and the &lt;a href=&quot;https://registry.modelcontextprotocol.io/?q=mulewatch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;MCP registry&lt;/a&gt;. Everything is tested against a live org except API Manager, because my test app didn&#x27;t have the &lt;code&gt;View APIs Configuration&lt;/code&gt; scope. If you run MuleSoft, try it and tell me what breaks. Issues and PRs are open on &lt;a href=&quot;https://github.com/brbousnguar/mulewatch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;MuleSoft and Anypoint Platform are Salesforce trademarks. mulewatch is an independent project.&lt;/em&gt;&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>My README generator isn&#x27;t allowed to make things up</title>
    <link rel="alternate" type="text/html" href="https://heybrahim.com/notes/vibetidy-grounded-readme.html"/>
    <id>https://heybrahim.com/notes/vibetidy-grounded-readme.html</id>
    <published>2026-09-18T00:00:00Z</published>
    <updated>2026-09-18T00:00:00Z</updated>
    <summary>vibetidy writes READMEs for AI-generated repos, but the LLM only formats facts a scanner verified. Building it, CI caught two things the project advertised that didn&#x27;t work, and npm refused a name that looked free. Here&#x27;s what I learned.</summary>
    <category term="LLM"/><category term="developer tools"/><category term="Node.js"/><category term="npm"/><category term="CI"/>
    <content type="html">&lt;p&gt;Ask an LLM to write a README and you get something long, confident and partly invented: flags that don&#x27;t exist, env vars nobody reads, a setup step from a different project.&lt;/p&gt;
&lt;p&gt;So in &lt;a href=&quot;/projects/vibetidy.html&quot;&gt;vibetidy&lt;/a&gt; the LLM doesn&#x27;t get to decide what&#x27;s true. A scanner reads the repo, and the model only turns those verified facts into prose.&lt;/p&gt;
&lt;p&gt;Funny thing: while building a tool about not advertising what isn&#x27;t real, CI caught me doing exactly that. Twice.&lt;/p&gt;
&lt;h2 id=&quot;the-scanner-is-the-product&quot;&gt;The scanner is the product&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;vibetidy readme&lt;/code&gt; scans the repository first: the manifest, scripts, dependencies, &lt;code&gt;.env.example&lt;/code&gt;, entry points, the folder tree, the existing README. Only those facts go to the model, and the prompt says it plainly: never invent a feature, a flag, an environment variable, a dependency or a version.&lt;/p&gt;
&lt;p&gt;When the facts are thin, you get a short README that&#x27;s right instead of a long one that&#x27;s plausible. And you can check what the model would see before spending a token:&lt;/p&gt;
&lt;pre data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx vibetidy readme --print-context&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No API key needed for that. It prints the scanned facts and the full prompt, and exits.&lt;/p&gt;
&lt;h2 id=&quot;bug-one-a-flag-i-documented-but-never-registered&quot;&gt;Bug one: a flag I documented but never registered&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;issue-check --help&lt;/code&gt; listed &lt;code&gt;--no-changelog&lt;/code&gt;. The README&#x27;s options table listed it too. Using it exited with &lt;code&gt;Unknown option &#x27;--no-changelog&#x27;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The flag was never registered in the parser. Node&#x27;s &lt;code&gt;parseArgs&lt;/code&gt; only honours a &lt;code&gt;--no-&lt;/code&gt; prefix with &lt;code&gt;allowNegative&lt;/code&gt;, which needs a newer Node than the &lt;code&gt;&amp;gt;=20.10.0&lt;/code&gt; the package advertises. So it became a plain &lt;code&gt;--skip-changelog&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The fix was one line. The interesting part is that nothing caught it: the docs looked right, the code read plausibly, every test passed. So I added a test for the whole class of bug. It pulls every flag out of each command&#x27;s &lt;code&gt;--help&lt;/code&gt; output and asserts the parser accepts it:&lt;/p&gt;
&lt;pre data-lang=&quot;js&quot;&gt;&lt;code&gt;for (const flag of flagsIn(help)) {
  const res = runCli([command, flag, &#x27;x&#x27;, &#x27;--help&#x27;]);
  assert.notEqual(res.status, 2, `${flag} is documented but rejected`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make sure it bites, I planted a fake &lt;code&gt;--phantom-flag&lt;/code&gt; in the usage text. The suite failed with the exact rejection message.&lt;/p&gt;
&lt;h2 id=&quot;bug-two-the-tests-never-ran-on-node-20&quot;&gt;Bug two: the tests never ran on Node 20&lt;/h2&gt;
&lt;p&gt;Opening that PR turned CI red on &lt;code&gt;main&lt;/code&gt;: 5 legs out of 9.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; ran &lt;code&gt;node --test &quot;test/**/*.test.js&quot;&lt;/code&gt;. Glob support in &lt;code&gt;node --test&lt;/code&gt; only landed in Node 21. On Node 20 the quoted pattern is taken as a literal path, so every 20.x leg failed with &lt;code&gt;Could not find .../test/**/*.test.js&lt;/code&gt;. The suite had never run on the Node version &lt;code&gt;engines&lt;/code&gt; promised.&lt;/p&gt;
&lt;p&gt;The fix is almost funny: plain &lt;code&gt;node --test&lt;/code&gt;, with no pattern at all. Default discovery has worked since Node 18. CI went green on all nine legs (Ubuntu, macOS and Windows, times Node 20, 22 and 24).&lt;/p&gt;
&lt;p&gt;Same class as bug one: something advertised that didn&#x27;t work. The 9-leg matrix is why I found both.&lt;/p&gt;
&lt;h2 id=&quot;npm-said-no-to-a-name-that-looked-free&quot;&gt;npm said no to a name that looked free&lt;/h2&gt;
&lt;p&gt;The tool started as &lt;code&gt;tidyrepo&lt;/code&gt;. &lt;code&gt;npm view tidyrepo&lt;/code&gt; returned a 404, so the name looked available.&lt;/p&gt;
&lt;p&gt;Then &lt;code&gt;npm publish&lt;/code&gt; failed with a 403. npm&#x27;s similarity check strips punctuation, so &lt;code&gt;tidyrepo&lt;/code&gt; collided with an existing &lt;code&gt;tidy-repo&lt;/code&gt;. A 404 isn&#x27;t proof a name is free: check the hyphenated and underscored versions too.&lt;/p&gt;
&lt;p&gt;It&#x27;s &lt;code&gt;vibetidy&lt;/code&gt; now, which honestly fits better.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;p&gt;It&#x27;s MIT, zero runtime dependencies, and works with OpenAI, OpenRouter, Anthropic, Groq, DeepSeek, Together, or a local Ollama:&lt;/p&gt;
&lt;pre data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx vibetidy readme --print-context      # see the facts, no key needed
npx vibetidy readme                      # generate, review the diff, confirm
npx vibetidy issue-check --install-hook  # nag on feature commits with no issue&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it writes something your repo doesn&#x27;t back up, that&#x27;s a bug. Open an issue on &lt;a href=&quot;https://github.com/brbousnguar/vibetidy&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
</feed>
