Building Sakhi: Hindi Voice-to-Form for India’s ASHA Workers, Solo in Six Weeks

TL;DR — Six-week solo build of a Hindi voice-to-form pipeline for India’s ~1 million community health workers. Two deployment modes: a workstation path with Whisper + Gemma 4 E4B on Ollama, and a fully offline on-device path running Gemma 4 E2B INT4 on the Cactus SDK on Android. Submitted to Kaggle’s Gemma 4 Good Hackathon. Source on GitHub, fine-tune on Ollama.

The problem

India’s 1 million Accredited Social Health Activists (ASHAs) handle the last clinical mile for maternal and child health. They conduct 50+ million home visits a year — vitals, symptoms, counselling, danger-sign assessment. Every visit still ends with a paper form filled from memory and physically carried to the Primary Health Center on the next clinic day.

Danger signs that were observed — preeclampsia, postpartum hemorrhage, neonatal distress — sometimes never reach the clinical system in time for intervention.

Two compounding constraints make this hard to fix with conventional tooling:

  • Hindi voice, often in regional dialects. Cloud STT is unreliable on rural-clinical Hindi (published benchmarks: 27–70%+ WER, deletion-dominant — numbers and symptoms silently drop).
  • Connectivity is intermittent. Airplane-mode operation cannot be a fallback. It must be the default.

Architecture

Two deployment modes for how ASHAs actually work — a workstation in the health center, and the phone in the field:

Workstation path (PHC, GPU):
[Hindi Audio] → Whisper-Large CT2 → Hindi Normalization → Gemma 4 E4B (function calling)
                                                            ├── extract_form()
                                                            ├── flag_danger_sign()
                                                            └── issue_referral()

On-device path (Android, no network):
[Hindi Text] → Hindi Normalization → Visit-type detect → Gemma 4 E2B INT4 on Cactus
                                                          ├── extract_form
                                                          └── detect_danger

Workstation mode handles voice: a phone uploads audio to a shared PC at the sub-centre, Whisper-Large-V2 Hindi via CTranslate2 transcribes, Gemma 4 E4B Q4_K_M on Ollama extracts the structured form with native function calling. End-to-end 15–25 seconds on an RTX 5070 Ti.

Field mode runs the full pipeline (normalize → detect visit type → extract form → flag danger signs) entirely on-device. End-to-end 320.7s on a OnePlus 11R (Snapdragon 8+ Gen 1), zero network. The on-device LLM does Hindi text → form; voice routes to the workstation when WiFi returns (more on why below).

The hardest engineering call: leaving on-device voice OUT

I wanted on-device voice-to-form. A phone, no laptop, no network — that’s the cleanest pitch. I pulled it from the build instead.

Cactus SDK ships multilingual Whisper INT4 for transcription — no Hindi-specific checkpoint. The published numbers are bad:

  • 27% WER best-case on rural Hindi
  • 70%+ on clinical content
  • Error profile is deletion-dominant — numbers and symptoms silently drop while filler words survive

A missed BP reading is a missed referral. A demo where Sakhi says “BP normal” because the actual 155/100 was deleted during transcription is exactly the failure mode an ASHA cannot catch in the field.

So voice routes to the workstation where Whisper-Large-V2 Hindi runs. The on-device LLM handles Hindi text → form for the case where an ASHA types a quick note offline. Field mode also captures raw audio offline and syncs to the workstation when WiFi returns.

This was the most uncomfortable call of the build. The submission video shows raw on-device JSON output from text input instead of faking voice.

Anti-hallucination: model extracts, Python decides

The hardest problem isn’t getting Gemma to talk about a transcript. It’s getting it to stop inventing. Early prototypes:

  • Hallucinated patient names from generic forms of address (दीदी / बहन — Hindi for “elder sister” / “sister”, used informally for any woman regardless of relation).
  • Invented BP readings on routine visits that never mentioned vitals.
  • Turned counselling utterances (“eat iron-rich food, drink plenty of water”) into “danger signs.”

The pattern that stuck: Gemma proposes evidence; Python decides what counts. The LLM extracts only what was said — verbatim utterances, structured under the schema. Validation, range-checks, deduplication, blocklist filtering: none of that runs inside the prompt. It runs in code, against the transcript, after extraction.

Six layers of validation:

  1. Evidence length filter — danger signs with under 10-character evidence are dropped.
  2. Generic ASHA phrase blocklist — boilerplate (कोई तकलीफ़ हो तो फ़ोन कर दीजिए / “call me if there’s any problem”) filtered.
  3. Normal-value filter — signs citing benign values (110/70, बिल्कुल ठीक / “totally fine”, सामान्य / “normal”) stripped.
  4. Transcript grounding — evidence must appear verbatim in the transcript.
  5. Deduplication across overlapping danger signs.
  6. Form validation — strips invented patient names (दीदी/बहन patterns), default ages, phantom lab results; range checks on BP (60–250 / 30–150), Hb (3–20), weight (1–200), gestational weeks (1–45).

False-alarm rate on routine visits: 0.

Demographics never go through the LLM

Early prototypes asked Gemma to extract patient name, age, and household composition from the audio. It hallucinated names from दीदी and बहन, defaulted ages on under-specified utterances, invented household members.

The fix wasn’t prompt-tuning. It was structural: demographics enter as a typed header — the way every clinical EMR works. The LLM never sees the question. It only extracts what was said during the visit.

This pattern generalizes — any LLM-based structured extraction where the field is known-and-typed should not be in the prompt at all.

The Blackwell + Windows + Unsloth dead end

Unsloth’s bundled save_pretrained_gguf mmap-fails on Blackwell + Windows:

RuntimeError: unable to mmap ... [WinError 8] Not enough memory resources

WSL was out (CUDA passthrough for Whisper was already finicky in this setup). Linux dual-boot would have eaten two days I didn’t have.

I wrote scripts/export_merge.py — manual LoRA-into-base delta-merge in PyTorch — then handed the merged FP16 model to llama.cpp/convert_hf_to_gguf.py + llama-quantize Q4_K_M. The fine-tune ships on the Ollama registry through that workaround:

ollama pull tusharbrisingr9802/sakhi

A/B vs base on the eval rubric: 14/15 fine-tune vs 15/15 base. Base is the production path. The fine-tune is published for deployments that prefer English schema-label normalization (दस्त → Diarrhea, चक्कर → dizziness).

Reproduce it locally

The workstation stack is the primary path:

git clone https://github.com/Tushar-9802/Sakhi
cd Sakhi
pip install -r requirements-runtime.txt
ollama pull gemma4:e4b-it-q4_K_M
cd frontend && npm install && npm run build && cd ..
python api.py
# Browser: http://localhost:8000

Requires ~10 GB VRAM (E4B Q4_K_M is roughly 9 GB resident). Verifies function calling, normalization, the 6-layer validation, and schema correctness end-to-end. Voice-to-form, text-to-form, and queue-and-sync all run on this stack.

For the on-device Android path see the GitHub Release — prebuilt APK plus in-app SAF zip-import of the Cactus model. Cactus’s gemma-4-E2B-it INT4 build is gated on HuggingFace, so it isn’t redistributed; the import flow keeps the no-adb path open for reviewers.

What’s not in this submission

Full root-cause walkthroughs live in FAILURES.md in the repo:

  • No on-device voice — covered above. On-device LLM does Hindi text → form; voice routes to the workstation.
  • No real ASHA endorsement. Outreach didn’t land inside the deadline. Real-voice testing came from family help in Bareilly — Hindi-native readers on a real phone mic, three of four role-play scripts. Not a corpus.
  • Synthetic training data. 1,154 fine-tune examples and the 15-case automated eval are LLM-generated Hindi with gTTS audio.
  • Regional dialect coverage. Tested on standard Hindi from Bareilly + role-play scripts. Bhojpuri, Awadhi, Magahi, code-switched Marwari/Bhili are not validated.

What’s next

  • Partner with an ASHA training institute to collect 100+ hours of real ASHA home-visit audio under field conditions.
  • Fine-tune an IndicWhisper variant on that real audio for the on-device voice-in path that is not in this submission.
  • Harden integration with the official MCTS API so forms post directly into the NHM system instead of being exported as JSON/CSV.
  • Pilot with 10–20 ASHA workers in one rural block with before/after time-and-accuracy measurement.

Links

If any of the patterns above are useful in your own LLM extraction pipelines — the model-extracts/Python-decides separation, demographics-as-typed-header, or the Whisper-INT4-WER receipts argument for not shipping fake on-device voice — drop a note in the comments. I’m @Tushar-9802 on GitHub.

AI is slowly destroying open source and its not even done yet

With the wide adoption of AI, we have seen the positive and negative impact it has had on businesses across the board. Open source was supposed to be one of the biggest beneficiaries. The idea was that it would lower the barrier to contributing, help developers move faster, catch bugs earlier, make maintaining projects less of a grind.

What the ecosystem is actually facing looks nothing like the vision. The problems AI has introduced into open source are not small or temporary growing pains. They are hitting maintainers, contributors, projects, and the funding models that kept everything running. And it keeps getting worse.

Exhibit A: Vibecoded slop is the new foundation

One of the core benefits of open source is that you do not have to reinvent the wheel. If you need to convert units or handle dates, you pull a well-maintained package and move on. You trust the foundation so you can build something better on top of it. Except now, the foundation is being replaced by “vibecoded slop.”

We’re seeing a flood of AI-generated projects hitting registries with zero oversight. It gets pushed out, it looks fine on the surface, and people install it. This vibecoded slop is now the foundation others are building on. And when the foundation is bad, everything on top of it inherits that. We have seen a wave of application leaks and breaches lately and a significant number of them trace back to projects that were never properly looked at before people trusted them with real data. It is a chain of vulnerabilities waiting to be pulled.

Exhibit B: AI agents are drowning the people keeping open source alive

Open source maintainers have always had to deal with bad pull requests. That isn’t new. What is new is the volume.

AI agents are now being set up specifically to scan repositories, find open issues, generate code, and fire off pull requests automatically. They ignore the project’s contribution guidelines and code style completely. The guidelines are there for a reason, they make maintenance manageable and keep the codebase consistent, but the agents do not care about any of that.

Shadcn said it plainly in a tweet on March 8: “Mass-generating PRs with your agents and clawbots isn’t helping open source. It’s quietly burning out the people who actually maintain it. Please stop.”

The maintainers on the receiving end of all this are mostly volunteers. They are not paid. They do it because they care. And now instead of spending that energy on work that actually moves the project forward, they are stuck triaging noise. GitHub acknowledged how bad it had gotten and in February 2026 introduced two new repository settings so maintainers can disable pull requests entirely or restrict them to collaborators only. That a platform the size of GitHub had to build a feature just so maintainers could protect themselves from contributors says everything about where we are.

Exhibit C: Why would anyone bother anymore

Open source was built on a simple but powerful idea. You run into a problem, you work through it, and instead of keeping that solution to yourself you put it out there so the next person does not have to go through the same struggle. That act of sharing is the whole spirit of it. That is what built the ecosystem.

Stormy Peters, AWS Head of Open Source Strategy, put it in a way that probably resonates with a lot of developers right now “I generated it in three seconds. You could generate it in three seconds if you needed it. Why would I spend my time pushing it upstream when anyone could just generate it on demand?”

That question does not have an easy answer anymore. If any solution can be generated on demand, the motivation to package it, document it, maintain it, and share it starts to feel pointless. The whole value of putting something upstream was that it saved the next person the trouble. But if there is no trouble to save them from, why bother?

Conclusion

AI is not just introducing bugs or burning out maintainers, though it is doing all of that. It is making that choice feel irrational. Why share what anyone can generate? Why maintain what nobody values enough to contribute to properly? Why keep volunteering your time to triage noise?

Those questions do not have good answers right now. And the people who built the things you depend on are the ones sitting with them. Most will not make an announcement when they decide it is no longer worth it. They will just quietly stop.

Open source survived a lot of things. I am very sure it will survive with a lot of help from the community of course.

What I learned introspecting 922 npm MCP servers

TL;DR: We ran npx -y <package> against 922 npm-published MCP servers, sent them the JSON-RPC initialize and tools/list calls, and captured what they did. 359 responded. 563 failed in 15 distinct ways that say more about npm packaging than about MCP itself.

The stderr signature that broke 261 servers

[stderr] connecting to upstream...
[stderr] (no further output)
[timeout after 120s]

That is the single most common failure mode in the npm MCP ecosystem right now. The process spawns, the package loads, the constructor runs, and then the server tries to phone home to its upstream API before answering the protocol. The introspection runner waits 120 seconds and gives up. Two hundred sixty-one servers – 28% of everything published – never made it past their own startup.

If you maintain an MCP server and you connect to anything external during initialize, you are in this bucket. The fix is to defer the upstream connection until the first tool call.

What we actually ran

The protocol gives you a discovery method: tools/list. Send it after initialize and the server returns the full JSON Schema for every tool it exposes. No README scraping, no LLM interpretation, no guessing. It is the exact same thing your MCP client does when it connects.

The runner does this per package:

1. spawn:  npx -y <package> (over stdio)
2. write:  {"jsonrpc":"2.0","id":1,"method":"initialize",
            "params":{"protocolVersion":"2024-11-05",
                      "capabilities":{},
                      "clientInfo":{"name":"introspector","version":"1.0"}}}
3. read:   initialize response
4. write:  {"jsonrpc":"2.0","method":"notifications/initialized"}
5. write:  {"jsonrpc":"2.0","id":2,"method":"tools/list"}
6. read:   tool list
7. kill:   close stdin, SIGTERM

Concurrency was 8 in parallel, 120-second timeout per server, total wall time around 25 minutes for 922 packages. The output is one JSONL row per server with status, tool count, and (where available) the full input schema for every tool.

The 15 failure buckets

Every server that didn’t return a clean tool array was classified by inspecting the exit code, stderr, and the JSON-RPC error if there was one. The breakdown:

Status Count Meaning
ok 359 clean tools/list response
init_timeout 261 spawned, never answered initialize
npm_install_generic 172 npx -y itself failed
needs_cli_args 54 exited with usage error
needs_env_var 42 generic missing env var
broken_install 11 malformed package, bad main or bin
error 8 unclassified crash with non-zero exit
needs_setup_step 3 required a CLI setup wizard run first
needs_slack_token 2 refused without SLACK_BOT_TOKEN
needs_azure_creds 2 refused without Azure auth
tools_list_timeout 2 answered initialize, hung on tools/list
needs_google_creds 2 refused without Google auth
needs_stripe_key 1 refused without STRIPE_API_KEY
needs_config_file 1 refused without a config path
needs_external_runtime 1 shelled out to a binary that was not installed
needs_openai_key 1 refused without OPENAI_API_KEY

The credential-wall buckets (everything needs_*) add up to 109 servers, almost 12% of the published set. If you are populating an agent’s tool list by parsing READMEs, those servers register as 0-tool servers in your index. They are not 0-tool servers. They are 5-tool, 12-tool, 40-tool servers waiting for a key you didn’t supply.

Windows-specific gotchas

The runner is on Windows. Two things bite hard.

Spawning npx. npx on Windows resolves to npx.cmd, a batch script. Node’s child_process.spawn without a shell will not invoke a .cmd. The result is a flat ENOENT even though where npx shows it on PATH. The fix:

const child = spawn("cmd", ["/c", "npx", "-y", pkg], {
  stdio: ["pipe", "pipe", "pipe"],
});

You see this same pattern in every claude_desktop_config.json on Windows. If you are writing your own MCP client, you need it too.

UTF-8 stdio. The default code page on Windows is not UTF-8. If an MCP server writes a tool description that contains a non-ASCII character (German umlaut, em-dash, curly quote, anything), and you read its stdio without forcing UTF-8 decoding, you get a JSON parse error halfway through tools/list. Force it:

proc = subprocess.Popen(
    cmd,
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    encoding="utf-8",
    errors="replace",
)

Three servers in our run had tool descriptions with non-ASCII characters. Without the encoding flag, all three would have been miscounted as error.

What this says to MCP server authors

A few patterns from the data, addressed to anyone shipping a server:

  1. Don’t connect upstream during initialize. If your server contacts an API on startup, it will fail introspection and it will fail any client that wants to enumerate tools without first burning a credential. Lazy-connect on the first tool call.

  2. Don’t require CLI args to print your tool list. 54 servers exit with a usage error before they will even tell you what they expose. If you need a config path, take it from an env var or accept tools/list without it.

  3. Document the env vars in the README. The classifier successfully named the credential for the servers that wrote a clear “missing X” line to stderr. The ones that didn’t ended up in the generic needs_env_var bucket. That bucket is your bug report queue.

  4. Ship a real bin entry. 11 servers in broken_install had package.json that pointed at a file that doesn’t exist, or a main field that crashed on require. None of them needed credentials. They needed a publish-time smoke test.

The full dataset

The 9,922 tool schemas and the 922 server status rows are on HuggingFace as automatelab/mcp-servers-tool-catalog under CC-BY-4.0. Pipeline source is at AutomateLab-tech/mcp-tool-catalog, and it re-runs on the 1st of every month via GitHub Actions.
Product page: https://automatelab.tech/products/datasets/mcp-tool-catalog/

from datasets import load_dataset

servers = load_dataset(
    "automatelab/mcp-servers-tool-catalog", "servers", split="train"
)

# Just the unreachable ones, grouped by failure mode
from collections import Counter
print(Counter(r["status"] for r in servers if r["status"] != "ok"))

If your server is in a failure bucket and shouldn’t be, open a PR on the pipeline repo. The next monthly run picks it up.

No Dataset? No Problem. How I Curated a Custom AI Dataset From Instagram & Pinterest to Build a Pose Suggester

When you start a new Machine Learning project, you pray there’s a clean, ready-to-use dataset on Kaggle or Hugging Face.

But when I decided to build an AI-powered Pose Suggester—a system that analyzes a user’s background (like a cafe or a park) and overlays a suggested 2D stick-figure pose skeleton on their camera screen—I hit a massive wall: No such dataset existed.

Nobody had built an open-source dataset mapping lifestyle locations to “good” aesthetic poses.

If I wanted this project to exist, I had to stop looking for a dataset and start building one. Here is exactly how I built, annotated, and augmented a custom computer vision dataset from scratch.

Step 1: Defining the Taxonomy
Before scrolling mindlessly for images, I needed a strict data structure. If your categories are messy, your neural network will learn absolute nonsense. I broke my target universe down into 3 core environments, each with 2 framing subcategories:

Cafe Indoor (Full Body / Waist Up)

Nature/Parks (Full Body / Waist Up)

Urban Street (Full Body / Waist Up)

My target was to source 50 high-quality, distinct anchor images per subcategory, giving me a baseline of 300 reference images.

Step 2: Sourcing via Pinterest & Instagram
Where do you find the best examples of people posing naturally in everyday environments? Instagram and Pinterest.

I spent hours reverse-engineering what makes a photo “good” for these platforms, looking for distinct body compositions. However, building a dataset this way comes with strict engineering rules:

Avoiding Bias: I couldn’t just download photos of the same 5 influencers. The model needed to learn diverse body types, heights, and clothing to ensure the pose estimation wouldn’t fail in the real world.

Background Variety: “Nature” couldn’t just mean a green lawn; it needed to include forests, beaches, and hiking trails so the Scene Classifier wouldn’t overfit to a single shade of green.

Clarity: Every image needed a clear, unobstructed view of the primary subject so the pose landmarker wouldn’t get confused by crowded backgrounds.

Step 3: Automated Labeling (The MediaPipe Hack)
Manually labeling coordinates for 17 to 33 skeletal joints across hundreds of images sounds like a nightmare. To save my sanity, I engineered an automated pipeline.

Instead of hand-labeling, I ran MediaPipe’s Pose Landmarker over my curated directory.

Python

The core logic behind the automation pipeline

import mediapipe as mp

MediaPipe processes the curated image and auto-extracts joint coordinates

detection_result = landmarker.detect(mp_image)
keypoints = serialize_to_json(detection_result.pose_landmarks)
The script automatically detected the human subjects, extracted their coordinates, normalized the vectors relative to the torso scale, and saved them into a neat pose_library/ folder as JSON annotations. Boom. Zero manual coordinate labeling required.

Step 4: Multiplying Data with Smart Augmentation
300 images are great for a prototype, but a deep CNN like MobileNetV2 will overfit and fail on a dataset that small. I needed to scale my 300 images up to roughly 1,800 training samples.

Using the albumentations library, I applied strategic augmentations to simulate real-world conditions without breaking the underlying pose logic:

Brightness & Contrast Jitter: To simulate poor lighting inside dim cafes or harsh sunlight outdoors.

Blur & Noise: To prepare the model for shaky, low-quality phone cameras.

Horizontal Flips: To double the dataset instantly.

The Tricky Part: When you flip an image horizontally, your labels break! A person’s left hand becomes their right hand. My augmentation script had to explicitly intercept the MediaPipe keypoints and swap the left/right joint indices so the data remained perfectly accurate.

Key Takeaways from Building Data
Building your own dataset teaches you things a clean Kaggle download never can:

Data Quality > Model Complexity: A lightweight model like MobileNetV2 trained on tightly curated, high-quality data will effortlessly outperform a massive model trained on garbage data.

Think Like a Product Manager: Sourcing data forces you to think about how your users will actually use the app. Defining “Egocentric” (first-person/glasses) vs “Exocentric” (third-person/laptop) views early completely changed how I filtered my images.

Now that the dataset is verified and locked, Phase 2 is officially underway: fine-tuning the scene classifier and training the K-Nearest Neighbors (KNN) engine to match a user’s background with the perfect pose.

Have you ever had to build a dataset from scratch for a passion project? What did your pipeline look like? Let me know in the comments!

Doubling the speed of an already fast SQL parser using AI

Our SQL parsers are among the fastest, if not the fastest, in the world. Once you have something highly optimized, it’s very hard to get further tangible gains. Yet I managed to double their speed, and I wouldn’t have been able to do so without AI. For the not-so-optimized code, the gains were incredible. Binding is now up to 100x faster and memory consumption down by up to 60x.

I just checked the impact on SQL Tran and it now takes 20 seconds to parse SQL, perform a static analysis pass, and translate full 2.7 million SLOC Oracle schema to Postgres.

Authored by Damir Bulic (CEO, Spectral Core), this post originally appeared on the Spectral Core blog. Spectral Core builds database migration and SQL analysis tooling: our parsers cover every major SQL dialect and are a core part of what makes large-scale migrations fast.

The Heart of the AI Harness: A Knowledge Graph of the AI, by the AI, for the AI (Series Part 2)

Before / After — graph from code inference alone vs. knowledge graph with JSDoc as SSoT

Hi, I’m Ryan, CTO at airCloset.

Disclaimer: “cortex” and “cortex-product-graph” referenced in this article are internal code names for an AI platform developed in-house at airCloset. They are unrelated to existing commercial services such as Snowflake Cortex or Palo Alto Networks Cortex.

In Part 1 (Series Intro), I wrote about how AI handles PR reviews and incident response on top of a platform we call cortex. At the center of that flywheel is the Product Graph (implementation name: cortex-product-graph, or cpg) — a unified knowledge graph of code, docs, DB schemas, and infrastructure definitions, queryable through semantic search.

In Part 1, I described cpg at a high level: “all of cortex is indexed in one graph.” This post goes deeper — how it’s built, why we landed on this design, and what actually changed once it was in place.

Series Index

# Theme Key scene Article
1 Series intro: cortex’s harness PRs auto-merge / incidents self-heal before you notice ai-harness-intro
2 Product Graph (cpg) Code, docs, DB, infra unified into one graph this post ← you are here
3 AI PR review webhook → AI review → auto-fix → squash merge coming
4 Alert-Fix Alert → AI investigation → fix PR → auto redeploy coming
5 Observability + quality gates OTel/Faro stack + “non-loweriable” quality design coming
6 Non-engineer dev environment Business members opening PRs, quality via AI review coming

Start with One Scene

“I want to change the calculation logic behind the ‘bug rate’ KPI on the dashboard. Where is it, and what might break?” — imagine that question comes up before you touch any code.

When you ask an AI this directly, with no function name and no file path given, it hits cpg with a semantic search and pulls the relevant nodes in one shot. What comes back isn’t just functions — it includes BigQuery tables and API endpoints alongside the code. And at the end of the response, there’s a “next action candidates (Runbook)” block that tells the AI to re-probe starting from the BQ table with the most reads and writes flowing through it.

The final answer looks like this:

  • Calculation site: calculateRatePer100pt / calculateBugCount — both pure functions with no I/O side effects; safe to change in isolation
  • Writers (upstream): syncKpiMetrics / writeKpiMetrics / backfillKpiMetrics all write to the kpi_bug_rate_per_100pt table; these are the real aggregation batch jobs
  • Readers (downstream): BigQueryKpiRepository.getSummaryByDate reads via BigQuery → /kpi/bugs API → KPI dashboard page
  • Related docs: docs/generator/kpi.md defines bug rate; updating the code without updating docs would leave them stale

“Update the docs together, and schedule the deploy when the aggregation batch isn’t running” — that’s a decision you can make with confidence.

I personally know all this — I wrote it. But that’s exactly the problem: anyone else who wanted to touch this had to track me down. Three months ago, “finding out where something lives and what would break” meant finding me. Now, this same investigation is done by PMO members (non-engineers) using cpg on their own. grep didn’t get them there; documentation didn’t get them there. One natural-language question did.

What makes that possible is cpg — a graph where you can follow “what you want to do” in plain language to the relevant nodes in one or two hops, even when you don’t know the function name. The Runbook structure — where the tool’s return value itself contains the next tool call to make — is what lets the AI re-select its starting point and drill deeper on its own.

That’s the setup. Now let me explain how it’s built.

What Static Analysis Alone Couldn’t Do

cortex has a separate system that graph-analyzes the production codebase using static analysis (I’ll write about this in its own post — just touching it here). It parses JS/TS code with AST analysis across our external-facing production repos, automatically extracting function call graphs, API endpoints, DB access patterns, and event pub/sub relationships.

This works well for what it does, and we still use it actively in the production repos. But when we tried applying the same approach to cortex itself, it didn’t get us where we wanted to go.

Three specific gaps:

  1. No context — nodes exist but carry no meaning. “What is this API for?” “Why does this column exist?” isn’t in the graph. Ask “where is the code that calculates the KPI bug rate?” and you’ll miss unless the function name happens to look like it.
  2. No entry point — you already have to know the file path or function name before search can start. “Let me go find it” doesn’t work.
  3. Explosion after 1–2 hops — starting from any node, related nodes multiply exponentially within a couple of hops, far exceeding what an AI can process in one context window. Trace results become too long to use.

The summary: mechanically accurate, but no semantic weighting. To be genuinely useful to AI, you need one more layer: “what matters, and why things are connected.“

Meanwhile, DB Graph Was Working

Around the same time, a different approach — the DB Graph MCP we’d built — was working exactly as intended.

DB Graph is an MCP server with access to 15 schemas and 991 tables inside cortex, supporting semantic search over tables and columns with AI-generated descriptions. A natural-language query like “tables related to return processing confirmation” would find semantically connected nodes even when the table name doesn’t contain those words.

After thinking about why this worked, the answer became clear: DB Graph has a business-context description attached to every node, and that description is what feeds into the embeddings. That semantic weight is what “finding by meaning” actually runs on.

Static-analysis code graph had none of that. Type relationships and call graphs exist — but “why this function exists” was never written anywhere.

The Hypothesis — Bring DB Graph’s Essence into the Code Graph

The hypothesis was simple:

“A business-context description on every node, loaded into embeddings” — if that’s the core of why DB Graph works, then doing the same thing for the code graph should structurally overcome the limits of static analysis.

The problem was: where do you put the “business context”?

All the options:

Location Example Problem
External docs Design docs / wiki / Notion Separate from code. Drifts instantly. Nobody maintains it.
External metadata Sidecar YAML / *.meta.json Dual-management. Breaks on rename.
Dedicated graph DB Write annotations directly into Neo4j / Neptune Dual-management again. Doesn’t show up in PR diffs — unreviewable.
TypeScript decorator @GraphNode({...}) in code Lives in the transpiled output = runtime dependency. Can’t be extracted by AST alone.
DSL file Custom .graph file format High learning cost. No editor support out of the box.
JSDoc comments @graph-business / @graph-connects Physically co-located with the code. Extractable by AST alone. Zero runtime dependency.

The choice of JSDoc over decorators was intentional:

  • Zero runtime dependency: decorators survive into the transpiled output and can affect runtime behavior. JSDoc has no executable runtime semantics; with production builds that strip comments, it leaves no runtime artifact.
  • Generalizes beyond TypeScript: the same @graph-* syntax can extend to Pulumi definitions in infra/ and Markdown frontmatter in docs/. Decorators are locked to TypeScript syntax.
  • Single AST pass: ts-morph can walk declarations and extract JSDoc in one scan. Decorators sometimes require type resolution, which slows builds.
  • Shows up naturally in PR diffs: JSDoc sits directly above the code it annotates, so when code changes, the JSDoc diff appears in the same file. Reviewers can’t miss it.
  • Doubles as documentation for both humans and AI: JSDoc already serves as IDE hover text and AI-readable context. Putting @graph-business there means it simultaneously explains the declaration to a human reading the code, and gives a coding AI semantic context about the surrounding functions. Graph metadata that also functions as inline documentation.

Note that the essence of this design is using parseable annotations co-located with code as the SSoT — TypeScript / JSDoc is just one implementation. The same pattern works in any language with comparable comment + AST primitives: Python docstrings + ast, Go comments + go/ast, Rust /// + syn. What matters isn’t where you write the annotations, but the invariant: “physically co-located with the code, extractable by AST alone.”

Same goes for the monorepo: this pattern doesn’t depend on cortex being a monorepo. If anything, its real value shows when repositories are split and AI can’t easily follow code across them. In a monorepo, the AI can still grep / read files across the whole tree; in a multi-repo, the cross-repo calls and data flows are the hard part to follow. Run the same build per repo, emit nodes / edges, aggregate into a central graph, and those cross-repo connections become reachable in one hop. We actually run a parallel knowledge graph over our external-facing production repos (multi-repo) using the same pattern — more on that in a separate post.

The Approach — Abandon Code Inference, Make JSDoc the SSoT

The code graph’s problem was no meaning. The answer is simple: embed the meaning directly in the code.

For cortex’s own code graph, we completely abandoned the approach of inferring graph structure from code. Instead:

Every declaration — function / class / method / API / Page / Cron / etc. — gets a dedicated JSDoc tag. The graph is assembled from those.

This means the SSoT (Single Source of Truth) for business context becomes the code itself. There’s no gap between docs and code, because the JSDoc in the code is the authoritative source. The structural problem of “AI makes mistakes because docs are stale” is resolved at the level of where the data lives.

Placing the two side by side — “a graph from code inference alone” versus “a knowledge graph with JSDoc as SSoT” — makes the difference in what’s carried on each node immediately visible:

Before / After — graph from code inference alone vs. knowledge graph with JSDoc as SSoT

Here’s a concrete example of the tags (from cpg’s own source):

/**
 * Set embeddings on nodes in place.
 * Compares textForEmbedding against existing BQ data; only re-generates
 * for nodes where the text has changed.
 *
 * @graph-stack product-graph
 * @graph-domain Engineering
 * @graph-business Compares hash of textForEmbedding against existing BQ nodes; re-generates
 *   embedding only for nodes where text has changed. Unchanged nodes reuse BQ embeddings.
 * @graph-connects cortex.product_graph_nodes [queries, via:id] read existing embeddings
 * @graph-connects vertex-ai-embedding [calls] generate embeddings for changed nodes
 */
export async function generateEmbeddings(
  nodes: ProductGraphNode[],
  options: { force?: boolean } = {},
): Promise<void> { ... }

What each tag does:

Tag Role
@graph-node Explicitly declares node type (defaults to Function)
@graph-stack The infra stack this declaration belongs to
@graph-domain Business domain (comma-separated, multiple allowed)
@graph-business What this declaration specifically does — the body of the embedding input
@graph-connects Connection targets (multiple allowed; via: for parameter-level tracking; none to explicitly declare no connections)

The key is that @graph-business feeds directly into the embedding input. It’s not the node name — it’s a natural-language sentence that carries semantic weight into search. In practice, almost all of these sentences are written by AI: during the normal flow of writing code in cortex, the AI writes the JSDoc alongside the code (and thanks to the ESLint enforcement below, it doesn’t forget).

Making Omissions Physically Impossible

This design collapses the moment someone leaves a tag out. One function without @graph-business = that function is invisible to semantic search. One without @graph-connects = the data flow through that function is absent from the graph.

So we built enforcement that makes omissions physically impossible:

  • 5 ESLint plugins — tag presence validation, syntax validation, naming convention enforcement (stack / domain allowlists), @graph-connects required, @graph-connects none misuse detection (flags when none appears on code that calls external services)
  • Automated PR review (Part 1 ③) — tags missing are flagged as [Graph] Critical; docs inconsistency is flagged as [Doc] Critical

The result: “write a declaration → business context is always written with it” holds as an invariant. Add a function → its meaning and connections are necessarily in its JSDoc.

One honest note: forcing “5 JSDoc tags on every declaration” on humans would blow up in code review within three days. Writing a @graph-business sentence per function, enumerating @graph-connects exhaustively, checking the naming allowlists — that’s genuinely tedious at scale.

This works because AI writes the code. Writing five JSDoc tags is rounding error on top of writing the code itself. With ESLint and automated review in the feedback loop, the AI doesn’t miss tags — and human reviewers only need to check “is this tag factually correct?” not “is it there?”

:::message
This design is one that can’t realistically be maintained when humans write code, but becomes viable the moment AI does. It’s an AI-first design. The premise of AI-first development is what lets business context be fixed in code as the SSoT.
:::

Where Hallucination Happens Shifts

Viewed from another angle, what’s going on here is that the location of hallucination shifts. Where you contain hallucination is, I think, fundamental to AI harness design.

As I wrote elsewhere, when you combine AI with a graph system, “hallucination doesn’t disappear — it just changes location.” For cpg, here’s where it lands:

  • Graph build phase: Zero hallucination. Context lives in the code itself, and both the ts-morph AST pass and the BigQuery MERGE are fully deterministic. No LLM is involved.
  • Graph query phase: Zero hallucination. The MCP tools return facts straight from BQ.
  • JSDoc writing phase: This is the entry point for hallucination. Whether @graph-business is factually accurate, or whether @graph-connects is exhaustively listed — these can go wrong since the AI is writing them.

But the entry point is locked down by automated PR review. Missing tags get [Graph] Critical; factual drift gets [Doc] Critical. When something’s wrong, either the AI that wrote the code or another reviewer AI catches it and fixes it.

The result: once data lands in the graph, it can be treated as deterministically sourced from reviewed code, not as a fresh generated answer that might hallucinate on every query. AI agents calling cpg don’t have to guard against “this might be a generated lie” on every returned node or edge. The tools can be designed as “return facts only” without compromise.

Build — AST to Graph via ts-morph

Once JSDoc is established as the SSoT, the rest is mechanics: extract it and assemble the graph. The implementation:

  1. AST-analyze JS/TS with ts-morph — walk every declaration (function / class / method / type / enum / variable / expression statement / export default / etc.)
  2. Extract @graph-* tags from JSDoc — collect all five tags and normalize into a ParsedGraphTags structure
  3. Generate nodes — use qualifiedName = "<filePath>:<name>" as the node ID
  4. Generate edges — one edge per @graph-connects entry, with via: / cardinality and other metadata preserved
  5. Generate embeddings — send @graph-business text to Vertex AI Embedding (gemini-embedding-2) and vectorize it
  6. Load into BigQuery — MERGE all nodes / edges into cortex.product_graph_nodes / cortex.product_graph_edges

Because @graph-business goes directly into the embedding input, querying “code that calculates the KPI bug rate” in natural language returns a hit based on semantic proximity of the description — even when the function name contains neither “bug” nor “rate.”

The overall flow: the three tracks (apps/ / infra/ / docs/) each go through their own parser, are merged into a single node set by the generator, and only nodes whose text has changed are sent to Vertex AI before being stored in BigQuery:

Build pipeline — assembling one knowledge graph from JSDoc, Pulumi, and docs

Build Cost Is Effectively Zero

The build runs automatically on push to main via GitHub Actions, using a differential embedding approach:

  • Compare textForEmbedding of each BQ node against the new text
  • Unchanged nodes reuse their existing BQ embeddings
  • Only changed nodes go to Vertex AI

A typical push changes a few dozen nodes, so cost is under $0.001. Full regeneration (for recovery, triggered via workflow_dispatch) is ~$0.075 for 8,000+ nodes.

Why BigQuery, Not a Graph Database

When people hear “knowledge graph,” they often imagine a dedicated graph DB (Neo4j, Neptune, Memgraph, etc.). cortex runs on just two BigQuery tables (product_graph_nodes / product_graph_edges). Three reasons:

  1. Different cost structure — dedicated graph DBs set a floor of “always-on cluster cost”; for the current implementation, BQ is storage + on-demand queries only. Even with continuous AI traffic, it’s clearly cheaper than running a server 24/7.
  2. Vector search / cosine similarity / SQL in the same place — BQ has VECTOR_SEARCH and ML.DISTANCE, so semantic search over @graph-business embeddings, filter by node properties, and adjacent-node JOINs can all live in one query. That matters when “semantic search + property filter + neighbor JOIN” is the standard access pattern.
  3. Migration-ready for GQL once BQ Graph goes GA — BQ already has Graph in BigQuery in Preview; once it ships GA, you can put a graph view over the existing tables and likely shift to MATCH (n)-[e]->(m) queries in GQL. The current table design is already migration-ready.

In short: get the graph DB’s future strength (GQL) while running on plain BQ tables today. Compared to adding a graph DB on top of a generic RAG stack (pgvector / Pinecone / etc.), fewer systems to operate and lower learning curve.

The Core Part Is Available as an Open-Source Sample

The “parse JSDoc annotations with AST analysis and output a graph” part is small enough to reproduce cleanly, so I published it as a working sample:

🔗 graph-jsdoc-extractor

It’s a ~500-line library that extracts @graph-* and outputs ndjson of { kind: "node", ... } / { kind: "edge", ... } objects. Comes with a pnpm run example that runs end-to-end. For those who just want to see the output format without cloning, the built ndjson is checked in: examples/sample/output.ndjson.

This is intentionally just the “turn code into a graph” part. The real value in cortex starts when docs and DB schemas land on the same graph — that’s the next section.

Connections — Landing Docs and DB on the Same Graph

Looking at the sample ndjson, a @graph-connects users [reads_from, via:id] entry has users stored as a raw string in targetId. Leaving that as-is means it’s just a string. Resolving users into a rich node carrying column definitions, partition info, and per-column descriptions — that’s where the resolution power of search takes a real step forward.

cortex does this in three directions.

1. DB Schemas as Nodes in the Same Graph

cpg ingests not just code but cortex’s DB schemas in the same build. A @graph-connects users [queries, via:id] on the code side gets resolved at build time into a rich Table node carrying column definitions, partition metadata, and descriptions (if the same-named stub exists, its internals are replaced while its ID and all inbound edges survive).

The key point: table and column descriptions aren’t AI-generated annotations attached after the fact — they’re pulled directly from the description fields in the Pulumi schema definitions. Here’s what that looks like (excerpt from cpg’s own table definition):

export const productGraphNodesTable = new gcp.bigquery.Table('cortex-prod-product-graph-nodes', {
  datasetId: 'cortex',
  tableId: 'product_graph_nodes',
  description:
    'Product Graph nodes — unified knowledge graph of code + DB + docs. ' +
    'Auto-generated from JSDoc @graph-* tags',
  schema: JSON.stringify([
    { name: 'id', type: 'STRING', mode: 'REQUIRED',
      description: 'Unique node ID (graphId:nodeType:filePath:name format)' },
    { name: 'nodeType', type: 'STRING', mode: 'REQUIRED',
      description: 'Node type — ApiEndpoint, BigQueryTable, Function, Module, Document, etc.' },
    { name: 'qualifiedName', type: 'STRING',
      description: 'Fully qualified name — filePath:exportName format' },
    // ...
  ]),
});

Both the table-level and column-level descriptions become the embedding input for semantic search directly from the Pulumi definition. The same philosophy as cpg’s JSDoc — “write the description at the place the thing is defined” — runs all the way through the DB layer. Fix a Pulumi description → semantic search improves. Same mechanics as fixing a JSDoc.

2. Docs Auto-Promoted to Nodes via Directory Convention

Markdown files under docs/ also land in the graph. The mechanism is simple: the directory structure is conventionalized so that which stack and domain each doc belongs to is deterministically resolvable:

docs/{category}/{name}.md

Examples from cpg itself:

  • docs/product-graph/README.md → stack: product-graph, domain: Engineering
  • docs/code-graph/README.md → stack: code-graph, domain: Engineering
  • docs/mcp/db-graph/README.md → stack: mcp-db-graph-server, domain: Engineering

Each file is ingested as a Document node in the graph, and a documented_by edge is auto-generated from code nodes whose @graph-stack matches the doc’s stack. Code under apps/graph/product/ all carries @graph-stack product-graph, so it’s automatically linked to docs/product-graph/README.md. Change code → related docs are already linked.

This means an AI reviewer can answer “did this code change leave related docs stale?” in one graph hop (that’s the source of the [Doc] Critical comments from Part 1).

3. Infrastructure Definitions as Nodes

@graph-* tags go on Pulumi code in infra/ too. An example from cortex’s own graph infrastructure:

/**
 * @graph-node {CronSchedule}
 * @graph-stack code-graph
 * @graph-domain Engineering
 * @graph-business graph-boundary-daily: runs cross-repository boundary analysis at 7:00 AM JST
 *   daily (auto-detecting API, DB, and Event connections across repos)
 * @graph-connects graph-index-job [triggers] trigger Cloud Run Job
 */
new gcp.cloudscheduler.Job(`${prefix}-graph-boundary-schedule`, { ... });

This becomes a CronSchedule node in the graph, connected to the target CloudRunJob node by a triggers edge. The Pulumi definition is itself a graph entry point — “what code runs in this cron?” is now answerable by graph traversal.

Result: Four Layers on One Graph

Adding the three together, the node types in the graph look like this:

Node type Source
Function / Class / Method Code (JSDoc)
ApiEndpoint / Page Code (JSDoc @graph-node)
BigQueryTable / FirestoreCollection (stub) Code @graph-connects targets
Table / Column / Schema (rich) Schema files defined in Pulumi
Document Directory parser over docs/
CronSchedule / PubSubTopic / CloudRunService infra/ JSDoc

Edge types correspondingly:

Edge type Role
calls / queries / reads_from / writes_to / publishes / triggers code → other nodes (@graph-connects)
documented_by code → Document (auto-generated on stack match)
HAS_TABLE / HAS_COLUMN Schema → Table → Column (DB side)
shares_topic Between boundary nodes sharing a topic

Code ↔ DB ↔ docs ↔ infra — all reachable in one hop on the same graph. This is what “Product Graph” means: cortex’s unified knowledge graph.

Here’s an actual visualization of a slice of cpg itself. Starting from generateEmbeddings (code), you can see cortex.product_graph_nodes (BigQueryTable) with its columns, the Pulumi table definition resource, docs/product-graph/README.md, external services like Vertex AI, and a separate layer’s graph-boundary-daily (CronSchedule) — all connected by edges on the same node set:

Product Graph — a knowledge graph with four layers on the same node set

Where the Sample Stops

graph-jsdoc-extractor intentionally leaves out:

  • Resolving @graph-connects targets to real node IDs (cortex uses a seven-stage resolver; the rules are project-specific)
  • Same-name merging (cortex promotes DB-schema-side rich nodes to replace stubs; the merge source is project-specific)
  • The docs directory convention parser (cortex’s docs/{category}/{name}.md convention is cortex-specific)
  • Embedding generation (Vertex AI setup is up to you)

These are parts where the right answer differs per project — naming conventions, where docs live, which embedding model to use, when to promote a stub to a rich node. Baking one answer into the sample library would make it harder to use, not easier. The sample draws the line at JSDoc → graph structure, and this article’s job is “here’s how we did it in cortex — translate it to your project’s context.”

MCP Tool Design and the Runbook Pattern

The graph is now assembled. Next: how AI uses it.

cpg runs as an MCP server (cortex-product-graph). From the AI’s side, three tools are visible, applying the three-layer tool design (search / detail / traverse) from the Agentic Graph RAG MCP post directly to cpg:

Tool Role
search_product_graph_nodes Find entry points (vector search + name search)
get_product_graph_node_detail Deterministically fetch detail by ID
trace_product_graph_connections BFS subgraph traversal (via_filter for parameter-level tracking)

Three layers only shows you what’s in the graph. For jumping from graph nodes to the actual data they point to, supplementary tools live in the same MCP:

Supplementary tool Role
read_file Pass a node’s path property directly to fetch source (Function / Class / Method / ApiEndpoint / Document — any code-origin node carries path)
grep_code Pattern search across the repository
git_blame Last author, commit, and timestamp per line
query_product_graph_bq Direct SQL against BigQuery. Find a BQTable node in the graph, then jump to its live data (executed via user OAuth, so BQ IAM applies as-is)
read_firestore / write_firestore Read/write Firestore collections. Find a FirestoreCollection node in the graph, then go to the live documents (Firestore access follows the same user / environment permission boundary; cpg provides the entry point, not a bypass around IAM)
list_product_graph_stacks / list_product_graph_domains Lists all stack / domain names present in the graph; useful for orienting before a search

In other words, cpg’s MCP is a two-tier design: the three-layer structure for graph traversal + supplementary tools for descending into live data (source code / BQ / Firestore). The AI can do “search by meaning → traverse by structure → pull live data” entirely within one MCP server.

Runbook Pattern — Return Values Contain the Next Action

Every MCP response ends with a “related nodes (next action candidates)” block. For example, after a search returns:

3 nodes found:
- apps/generator/kpi/src/kpi-calculator.ts:calculateBugCount (Function)
- backlog_no_embedding.kpi_bug_rate_per_100pt (BigQueryTable)
- /kpi/bugs (ApiEndpoint)

## Related nodes (next action candidates)

### 🛠 Code (1)
- apps/generator/kpi/src/kpi-calculator.ts:calculateBugCount
  → `get_product_graph_node_detail("apps/generator/kpi/src/kpi-calculator.ts:calculateBugCount")`

### 🗄 DB tables (1)
- backlog_no_embedding.kpi_bug_rate_per_100pt
  → `trace_product_graph_connections(start_node: "backlog_no_embedding.kpi_bug_rate_per_100pt", direction: "backward")`

### 🌐 API (1)
- /kpi/bugs
  → `get_product_graph_node_detail("/kpi/bugs")`

Copy-pasteable tool calls are lined up by node type, showing exactly what to call next. The AI gets new options on every call, so it never has to figure out “what should I do now?”

Here’s the AI ↔ MCP loop in diagram form. The MCP bundles next action candidates into every search response; the AI picks one and makes the next call, repeating:

Runbook pattern — tool return values contain the next tool call to make

usecase Parameter — Switching the Runbook

Every tool accepts a usecase parameter where the AI declares what kind of investigation it’s doing:

usecase Strategy (summary of what cpg optimizes for)
general Basic investigation with unknown entry point. Default.
design Understanding existing feature structure. Read business / connections via get_product_graph_node_detail. Deep trace is unnecessary; Document nodes take priority.
impact Trace upstream and downstream impact deeply. Hit trace_product_graph_connections with direction=both / max_depth=5. Code + DB + infra + schedules are all on the same graph, so one traversal covers a wide area.
test-create Test design. Fetch detail to read parameters and connected DB / called functions.
test-review Compare existing tests against implementation coverage. Cross-check branch structure of target Function / Method against test case count.
code-review Check impact of changes and detect @graph-business violations. Trace impact → detail to check business / source.
bug Deep trace from error origin. direction=both / max_depth=5 for upstream callers + downstream data flow.

The same search_product_graph_nodes call with usecase: "code-review" returns next action candidates optimized for “verify the change’s impact first.” With usecase: "bug" it returns candidates optimized for “trace deep from error origin + fetch logs.” The Runbook switches to match the declared intent.

This matters because having the AI declare “what kind of investigation I’m doing” yields different angles from the same graph. Auto Review internally fires with code-review; Alert-Fix fires with bug — the flywheel elements from Part 1 each run a different Runbook.

CLAUDE.md Convention — Forcing AI to Always Hit cpg First

Throughout this post I’ve said “the AI uses cpg,” but AI doesn’t spontaneously choose cpg. Claude Code defaults to grep / glob / file read as its first instinct. To flip that, the root CLAUDE.md in cortex opens with:

Product Graph MCP (cortex-product-graph)

This is the single most important asset in this repository. cortex-product-graph MCP indexes all code, DB schemas, docs, and infra into a unified knowledge graph with business context. It knows everything about this repository.

  • Always query Product Graph MCP first before grep/glob/file reads. It returns richer, contextualized results.
  • If Product Graph MCP is unavailable (auth expired, server down) and you are NOT in autonomous/auto mode, stop all work immediately and ask the user to authenticate. Do not proceed with degraded grep-only investigation.

Two things matter here. First, the explicit ordering — “cpg first, grep only as fallback.” Second, fallback to grep is explicitly forbidden if cpg is unavailable. Without that second clause, the AI happily degrades to “cpg seems down, I’ll just grep” and proceeds with stale context and wrong assumptions. With it, cpg unavailability is a hard stop, not a graceful degradation.

One clause in CLAUDE.md, and Claude Code’s first move on any code investigation is pinned to cpg. Article writing, Auto Review, Alert-Fix — all follow the same convention, so the entry point is always unified.

A Live Example — Investigating cpg with cpg

Enough abstraction. Let me walk through a real cpg query: using cpg to investigate cpg’s own builder core — the meta-example.

Step 1: Semantic search for “the code that extracts graph source data from code annotations”

No function name assumed. Just the intent in plain language:

search_product_graph_nodes(
  query: "code that extracts graph source data from annotations written in code",
  search_mode: "semantic",
  usecase: "design"
)

Top 5 results:

- apps/graph/product/src/parsers/jsdoc-parser.ts:applyGraphTag (Function)
- apps/graph/product/src/parsers/jsdoc-parser.ts:extractTagsFromNode (Function)
- packages/eslint-plugin-graph/src/utils/jsdoc-utils.ts:extractGraphTags (Function)
- apps/graph/product/src/parsers/jsdoc-parser.ts:parseJSDocExports (Function)
- packages/eslint-plugin-graph/src/utils/jsdoc-utils.ts:getGraphTagValue (Function)

The query contained neither “JSDoc” nor “@graph-*” nor “parser” — yet the intent found the right nodes via the @graph-business embedding. grep cannot do this.

Step 2: Trace downstream from that node (usecase: "design" prioritizes Documents)

trace_product_graph_connections(
  start_node: "apps/graph/product/src/parsers/jsdoc-parser.ts:parseJSDocExports",
  direction: "forward",
  usecase: "design"
)

Edges returned:

- parseJSDocExports --calls--> extractDeclarationsFromFile
- parseJSDocExports --calls--> extractTagsFromNode
- parseJSDocExports --reads_from[via:filePath]--> filesystem
- parseJSDocExports --documented_by--> docs/product-graph/README.md (Document)

The last one — documented_by — is the point: the edge from code to the Document node was auto-generated. Following it with read_file retrieves docs/product-graph/README.md — and with it, the background, design rationale, and tag specification for this implementation, all in one hop.

Step 3: The meta-structure — this article itself is written with cpg

This article was drafted by Claude Code, not by me — I provided direction and review. That Claude Code has cpg MCP connected, so every time I said “show a real example from cpg’s own code” or “use a cpg-related infra example,” Claude queried cpg to pull actual function names, JSDoc, Pulumi definitions, and docs structure, then embedded them in the text.

In other words: the generateEmbeddings JSDoc, the Pulumi productGraphNodesTable description, the graph-boundary-daily cron annotation, the auto-link to docs/product-graph/README.md — none of these came from my memory. Claude queried cpg and found the real artifacts. My role is only the review judgment: “this is right / this is wrong.”

This is the pattern repeating across all of cortex. Humans set the direction; AI uses cpg to verify and generate implementations / text / reviews. Part 1’s ③ Auto Review and ④ Alert-Fix run on the same structure. Article writing isn’t a special case — as long as cpg exists, AI-driven work always takes this shape.

What Changed / Bridge to Part 3

That covers the inside of cpg. A closing summary of how it affects cortex as a whole:

1. I stopped running grep

Without knowing file names or symbol names, I can get the relevant code back by just describing what I want to do. The combination of 120+ apps and a team of one works because of this, more than anything else.

2. Auto Review produces context-grounded comments

The [Graph] / [Impact] / [Doc] / [Security] level comments Part 1’s ③ Auto Review produces all stand on cpg. The substance is review carried out with the entire codebase as context — that’s the real benefit of the cpg integration.

3. Alert-Fix can trace from error origin to root cause

Part 1’s ④ Alert-Fix can hop from a Grafana alert → code → dependent tables → related docs in one graph traversal because cpg exists. It fires with usecase: "bug" and takes the shortest path from error to root cause.

4. The static-analysis code graph is working somewhere else

I said “we abandoned code inference” at the top, but that was specifically for cortex itself. For the external-facing production repositories (the core of the business), a different approach supplies context, and static analysis continues to run there. More on that in a separate post.

Most AI coding setups try to make the AI better at reading an unchanged repository. cpg takes the opposite approach: change the repository’s information structure so AI has a first-class semantic map to read. That’s the line between “another GraphRAG” and what cpg actually is.

In that sense, Product Graph is literally a knowledge graph of the AI, by the AI, for the AI: generated alongside AI-written code, maintained through AI review, and consumed by AI agents as their primary map of the product.

Coming up in Part 3: the full pipeline of automated PR review built on top of cpg — from GitHub webhook ingestion through AI review / automated fix / automated merge / parallel deploy. What happens when Auto Review fires with usecase: "code-review", how [Graph] Critical comments are generated, and the worktree mechanism that lets AI apply fixes and push back.

Continuous Delivery and Agents

There’s a moment in every team’s adoption of coding agents where the question shifts from “can it write the code?” to “can I trust what it wrote?” Throughput is no longer the bottleneck. Verification is.

This is not a new problem. We’ve been here before, with humans.

A note on terminology

Before going further, it’s worth being precise about what Continuous Delivery means because the term is often conflated with Continuous Deployment, and the difference matters here.

Continuous Deployment means every change that passes your automated checks goes straight to production. No human in the loop, no held-back releases, no batching.

Continuous Delivery, as Dave Farley and Jez Humble defined it in their 2010 book of the same name, is the discipline of keeping software in a state where it could be released at any time. The decision of whether to actually deploy is a business decision, made by humans, on a cadence that suits the product. What CD guarantees is not that you ship constantly — it’s that you could.

That distinction is the whole point. Continuous Delivery is about readiness. It is the system that proves, on every change, that the software still works the way you said it would.

The throughput problem

Agents produce code faster than humans can read it. This is not a flaw; it’s the entire premise. But it changes the economics of a team. The cost of producing a change collapses. The cost of verifying a change does not.

If your verification process depends on a human carefully reading every diff before it lands, you’ve built a system where the agent must wait for the human. The agent’s throughput is capped at the human’s reading speed. You bought a sports car and parked it in traffic.

The alternative is to invest in a verification process that scales with code volume rather than with human attention. This is what Continuous Delivery has been quietly building for fifteen years.

What CD actually provides

A mature deployment pipeline is a series of progressively more expensive, progressively more confident checks: compile, unit tests, integration tests, contract tests, performance tests, security scans, deployment to staging, smoke tests in staging. Each stage is automated. Each stage runs on every change. No change moves forward without passing the prior stage.

The pipeline is the source of truth about whether a change is safe to release. It doesn’t care who wrote the change. It doesn’t ask whether the author was tired, or junior, or in a hurry, or in fact a human at all. It applies the same gates to every change.

This is exactly the property you want when an agent is one of your contributors.

Farley’s deeper point

In Modern Software Engineering, Farley argues that the real practice of software engineering is the management of two things: complexity and uncertainty. The techniques he advocates (working in small batches, automating feedback, separating concerns, treating engineering as an empirical discipline) are not arbitrary best practices. They are the techniques that let humans make confident decisions in the face of systems they cannot fully hold in their heads.

Agents are subject to the same constraints, for the same reason. An agent does not “understand” your codebase in some privileged way that exempts it from needing feedback. It is making local decisions under uncertainty, and it benefits enormously from a system that tells it quickly whether those decisions held up.

Small batches help: an agent given one well-scoped task produces one well-scoped change that the pipeline can verify in isolation. Fast feedback helps: an agent that gets a failing test back in two minutes can fix it; an agent that gets it back after an overnight build has already moved on to something else. Automated quality gates help: the agent doesn’t need to be trusted in some absolute sense, because the pipeline doesn’t trust anyone.

Trust through accountability

The instinct, when you start using a coding agent, is to ask how much you can trust it. This is the wrong question, because the answer is “it depends, and you won’t know until something breaks.”

The better question is: what does it take to hold any contributor accountable for the changes they introduce? Once you have a real answer to that, the agent is just another contributor running through the same gates. You stop relying on faith in the model and start relying on the evidence the pipeline produces.

This is the same shift CD asked of human teams. The argument against CD was rarely that the practices were bad. It was that they felt unnecessary if your developers were good enough. Farley’s response, repeated patiently across both Continuous Delivery and Modern Software Engineering, was that “good enough” is not a strategy. Systems that depend on the unbroken vigilance of skilled humans fail the moment vigilance lapses, which it always eventually does. Systems that depend on automated, repeatable verification do not.

Agents make this argument harder to dodge. Whatever residual confidence you had in human carefulness as a safety net, the agent is going to outrun it. The pipeline is now the safety net. If you don’t have one, you’re about to find out why you needed it.

Where to start

If you want agents to do more of your work, the highest-leverage thing you can build is not a better prompt or a more capable model. It’s the boring infrastructure Farley has been describing since 2010: a pipeline that runs on every change, tests you trust, environments that mirror production, feedback that arrives in minutes rather than days.

Continuous Delivery was always about giving teams the confidence to move faster without breaking things. Agents are the case where that confidence becomes load-bearing.

Trunk-Based Development: From Chaos to Flow

If you’ve followed the first two parts of this series, you know the hard truth: most teams aren’t actually doing Trunk-Based Development. They are doing “Short-lived Feature Branching” with better branding. We’ve talked about why your PRs are still too big and why the 6-month pull request is a parallel universe that kills delivery.

But how do you actually fix it? Knowing small PRs are better is easy. Changing a team’s habits is the hard part.
This is the practical roadmap for moving from long-lived branches to real trunk-based flow.

Previous posts about Trunk-Based Development: Pt. 2 — https://codecraftdiary.com/2026/04/29/trunk-based-development-your-pull-requests-are-still-too-big/

Pt. 1 https://codecraftdiary.com/2026/04/04/trunk-based-development-why-most-teams-think-they-use-it-but-dont/

Phase 1: Fixing the Foundation (The Infrastructure)

You cannot do trunk-based development with slow pipelines. If your CI/CD suite is a bottleneck, your developers will naturally revert to batching work to “save time”.

1. The 10-Minute Rule for CI

In 2026, speed is a requirement, not a luxury. If your CI takes 20 minutes, there is friction; if it takes 60 minutes, people stop merging frequently.

  • Target: Aim for CI under 10 minutes.
  • Action: Parallelize your test suites. If a test is flaky, don’t ignore it — fix it or delete it. A flaky test suite is a debt that destroys the confidence needed for frequent merges.

2. Radical Observability

To merge to main multiple times a day safely, you need to know exactly what is happening in production.

  • Action: Implement real-time logging and alerting. If you merge a small change and the error rate spikes, you should know within seconds, not after a customer support ticket arrives.

Phase 2: Mastering the Tools of “Incomplete” Work

The biggest fear in TBD is merging work that isn’t “done”. To overcome this, you must decouple deployment from release.

3. Feature Flags as a Standard
Feature flags are the missing piece for most teams. They allow you to merge partial work and control exposure without waiting for the entire feature to be polished.

The Strategy: Wrap new logic in a toggle. This lets the code live in the main branch, deployed to production but hidden from users until it’s ready.

The Rule: A feature flag must exist from the start, not as an afterthought.

4. Branch by Abstraction for Large Changes
When you are doing a complete architectural overhaul, do not create a “v2-architecture” Git branch. That is a recipe for a merge nightmare.

The Strategy: Keep both the old and new architectures in the main branch simultaneously.

The Execution: Use an abstraction layer (an interface or wrapper) to toggle between the old and new logic. Run “dark launches” where the new code executes, but you ignore the results or simply compare them against the old version to gain confidence.

Phase 3: Rewiring the Process (Small PRs)

Small pull requests are the backbone of TBD. A large PR is a cognitive nightmare that leads to shallow reviews and delayed deployments.

5. Enforce Hard PR Size Limits
Don’t make small PRs a suggestion; make them a constraint.

The Rule: Set a soft limit of ~300 lines and a hard limit of ~400–500 lines per PR.

The Logic: Constraints force better behavior. If a task is too big, it forces the developer to think about how to slice it vertically.

6. Vertical vs. Horizontal Slicing
Stop splitting work by “Backend PR,” “Database PR,” and “Frontend PR”. This creates artificial dependencies and forces you to wait until all are done before merging.

The Strategy: Slice vertically. One PR should deliver a minimal end-to-end functionality (even if hidden by a flag), followed by another PR that extends that behavior.

Phase 4: The Cultural Shift (The Hard Part)

Trunk-based development is 10% tooling and 90% discipline.

7. Optimize for Review Speed, Not Just Quality

If reviews take days, developers will batch work to avoid the “waiting tax”.

Expectation: A PR should be reviewed within a few hours, not days.

Technique: If a PR is small (under 300 – 400 lines), it takes 15 minutes to review. If it’s still stuck, do live reviews or pair programming to clear the logjam.

8. Accept “Ugly but Correct”

Flow matters more than perfection.

The Mindset: It is better to merge a small, slightly imperfect (but safe) change today than a “perfect” massive change next week. You can refactor and improve incrementally once the code is integrated -> but don’t forget to refactor.

Phase 5: The Cleanup and AI Reality

AI-assisted coding is generating more code than ever. If you don’t have small changes and fast integration, your workflow will collapse under the sheer volume of AI-generated changes.

9. The Non-Optional Cleanup
Feature flags and abstractions are great, but they create technical debt if left forever.

The Rule: Once a rollout is 100% successful, deleting the old code and the flag is part of the original task, not a “nice-to-have” for later.

10. Track Your Behavior (Metrics)
Stop guessing if you are doing TBD. Measure it.

KPIs: Track average PR size, PR lifetime, and merges per developer per day.

Reality Check: If your PRs live for days and contain thousands of lines, you are still doing feature-branch development.

Common Failure Mode

Teams adopt feature flags but still keep long-lived branches.

That is not trunk-based development.

The branch lifetime matters more than the branching strategy itself.

Final Thought

Trunk-based development feels uncomfortable at first because it goes against the natural desire to be “complete” and “polished” before sharing work. It requires you to prioritize the system’s flow over your individual comfort.

If you fix just one thing this month, make your pull requests radically smaller. Faster reviews, fewer bugs, and smoother delivery will follow. Everything else is just details.

UptimeRobot Free Plan in 2026: The Limits That’ll Actually Bite You

If you’re still running your SaaS on UptimeRobot’s free plan, there’s a change from December 2024 you need to know about.

Effective December 1, 2024, UptimeRobot updated their Terms of Service: the free plan is for personal, non-commercial use only. That means monitoring your SaaS product, any client work, or any revenue-generating project is now explicitly prohibited. Violations can result in account suspension.

Most devs I’ve talked to have no idea this happened.

The actual free plan specs in 2026

Here’s what you get:

Feature Free
Monitors 50
Check interval 5 minutes (fixed)
Integrations 5 of 12 (email only, no Slack/webhooks)
Status pages 1 (no custom domain)
Log retention 3 months
API 10 req/min
Maintenance windows ❌
Commercial use ❌ since Dec 2024

The 5-minute interval in real terms

Every 5-minute check interval is a 299-second worst-case detection gap. Your site can be completely down for 4 minutes and 59 seconds before an alert fires.

That’s not hypothetical — it’s the math. If you check every 300 seconds and the outage starts 1 second after a check completes, you won’t know for another 299.

Cockroach Labs’ 2025 resilience research found 41% of companies find out about downtime from customers first. Five-minute polling is a direct contributor to that number.

The gaps that hurt most in a dev workflow

No webhooks or Slack on free. UptimeRobot’s 5 free integrations don’t include the ones you actually want. Slack, Discord, webhooks, and PagerDuty all require the Team plan at $29/month. You’re getting email alerts only.

No maintenance windows. Deploying? Every intentional restart triggers real alerts. There’s no suppression without upgrading.

API is rate-limited to 10 req/min. Paid plans go to 5,000 req/min. If you’re automating anything on top of the API — custom dashboards, scripts, status integrations — you’ll hit this ceiling fast.

3-month log retention. Not enough for anything resembling SLA reporting or historical trend analysis.

The Solo plan gotcha

The base Solo tier is advertised at $7/month — but it gives you 10 monitors, fewer than the free plan’s 50. To match 50 monitors at 60-second intervals, you’re paying ~$15/month. The $7 entry price is for a plan that’s less capable monitor-count-wise than free.

Free alternatives worth considering

Tool Monitors Interval Commercial Status Page
UptimeRobot 50 5 min ❌ 1 (no custom domain)
BetterStack 10 30 sec ✅ 1
HetrixTools 15 1 min ✅ Unlimited
StatusCake Unlimited 5 min ✅ Basic
Stillup 3 1 min ✅ 1

Note: Freshping is gone — shut down March 6, 2026. Don’t rely on any article that still recommends it.

Bottom line

If you’re a dev using UptimeRobot free for personal side projects, it’s still a solid tool. 50 monitors covers a lot.

If you’re using it for a SaaS or any commercial project, you’ve been in violation of their ToS since December 2024. Either upgrade ($15/month for monitor parity with 60-sec checks), or switch to something that still allows commercial use on free.

Canonical post: UptimeRobot Free Plan Limits in 2026 on stillup.org

AWS Bedrock prompt caching has a hidden cost most people miss

I moved a workload from the Anthropic API to AWS Bedrock for compliance reasons. Same Claude model. Same prompts. My monthly bill went up about 35%.

I assumed Bedrock just costs more. It does, a little, but not 35% more. The real story was prompt caching.

On the Anthropic API, prompt cache writes cost 1.25x the input price and reads cost 0.1x. On Bedrock, the cache-write price for Claude is the same multiplier but reads are slightly more expensive per token, and the cache TTL is shorter on some configurations. Combine that with the way Bedrock charges for cache-aware models and the breakeven hit ratio is around 30%.

If your cache hit ratio is below 30%, every cache write is costing you more than the reads save.

I was at 14%.

How I figured it out

I built bedrock-kit to wrap my Bedrock calls and surface the cost breakdown per call. It is opinionated. It does three things:

  1. Pulls input, output, cache-read, and cache-write token counts from every response.
  2. Computes USD cost using the current Bedrock price sheet for that model and region.
  3. Tries to repair malformed JSON output before raising.

That last one is unrelated but worth its weight. More below.

The hit ratio surface

from bedrock_kit import BedrockClient

client = BedrockClient(model="anthropic.claude-3-5-sonnet-20241022-v2:0")

result = client.complete(
    system=long_system_prompt,
    messages=[{"role": "user", "content": "..."}],
    cache=True,
)

print(result.cost)
print(result.cache_stats)

Output:

cost: $0.0042
  input:        $0.0008  (2700 tokens)
  output:       $0.0030  (900 tokens)
  cache_read:   $0.0001  (100 tokens, 1% of input)
  cache_write:  $0.0003  (2600 tokens)
cache_stats:
  read_ratio: 0.04
  status: COLD  (write > read, you are paying the tax)

The status field is the part that woke me up. It tells you whether the cache is actually helping. COLD means writes exceed reads. WARM means roughly breakeven. HOT means reads dominate and you are getting your money’s worth.

Over a few days of running this, I saw that my system prompts were getting evicted faster than I expected on Bedrock. The 5-minute TTL was the problem. My traffic was bursty and a lot of my prompts were arriving 6-7 minutes apart.

I batched my requests into tighter windows and the hit ratio went from 14% to 71%. Bill dropped to about 5% above where it was on the Anthropic API. That I could live with.

The JSON repair part

The other thing in this library is JSON repair. Bedrock will sometimes return Claude output wrapped in fenced code blocks even when you ask for JSON only. Or with a trailing comma. Or with the JSON object truncated because you hit max_tokens.

result = client.complete(
    messages=[...],
    response_format="json",
)

data = result.json  # repaired, parsed dict

The repair runs three passes:

  1. Strip markdown fences (

    `

`and `

json `).

  1. Find the largest balanced {...} or [...] substring.
  2. Remove trailing commas inside objects/arrays.

If all three fail, you get the raw text and a JsonRepairError you can handle. About 95% of the malformed outputs I have seen are recoverable with these three passes. The remaining 5% are usually a truncated response, which is a real bug, not a parse bug, and you want to know about it.

Throttle handling

Bedrock throttles aggressively in some regions. The library has a built-in exponential backoff with jitter for ThrottlingException. Same defaults that work for me: 5 retries, base 1s, cap 30s, full jitter.

python
client = BedrockClient(
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
retry_throttle=True, # default
max_retries=5,
)

If you want to handle it yourself, set retry_throttle=False and you get the raw boto3 error.

What this is not

This is not a full SDK. It is a thin wrapper around boto3 with a few quality-of-life things. If you want streaming, raw byte access, or anything past the basic complete-and-cost flow, drop down to boto3 directly. The client exposes the underlying session so you can mix.

It also does not call out the cache cost story automatically. You have to log the cache_stats field and look at it. I would like to add a background warning when status is COLD for more than N requests in a row. That is on the list.

Repo

GitHub: https://github.com/MukundaKatta/bedrock-kit
PyPI: pip install bedrock-kit

If you are on Bedrock and you turned on prompt caching because it was free on the Anthropic side, check your hit ratio. The breakeven on Bedrock is higher than you think.