# koborin.ai - EN
> Last updated: 2026-10-11
## Welcome to koborin.ai
URL: https://koborin.ai/index/
import SiteImage from '../../components/SiteImage.astro';
import ThemedSiteImage from '../../components/ThemedSiteImage.astro';
import GitHubSponsorsButton from '../../components/GitHubSponsorsButton.astro';
import heroLight from '../../assets/og/koborin-ai-hero-light.png';
import heroDark from '../../assets/og/koborin-ai-hero-dark.png';
import bookCover from '../../assets/life/reflections-on-2025/book-cover.png';
import terradartOg from '../../assets/_shared/building/terradart-og.png';
import jevSpecOg from '../../assets/og/jev-spec-introduction.png';
import contextlintOg from '../../assets/_shared/building/contextlint-og.png';
import gcpCostMcpHero from '../../assets/_shared/building/gcp-cost-mcp-hero.jpeg';
import abletonOscMcpKey from '../../assets/_shared/building/ableton-osc-mcp-key.png';
## About This Proving Ground
This is a personal technical proving ground where I explore, document, and share my journey through modern software development — shaped through repeated cycles of *hypothesize → verify → return*.
---
## Who runs this
Built by [@nozomi-koborinai](https://github.com/nozomi-koborinai).
- 🏢 Serverless Application Developer at a Google Cloud Partner company in Japan
- 🏆 Google Cloud Partner Top Engineer 2025 & 2026 (Application Modernization)
- 📖 Contributing to developer communities through Genkit books, OSS, and conference talks
- 🎯 Specialized in DDD and Clean Architecture for quality assurance
- 🤖 Exploring AI-driven development — building tools like [contextlint](https://github.com/nozomi-koborinai/contextlint) and [jev-spec](https://github.com/nozomi-koborinai/jev-spec)
Support this proving ground
### Background
I graduated from an IT technical college in March 2019, where I built my foundation in object-oriented programming with Java.
During my first four years, I worked on enterprise legacy systems while teaching at App University (Japan's app development engineering community) and co-authoring a book on Flutter design patterns.
Currently working across cloud architecture, pre-sales, and team leadership at a Google Cloud Partner company — while exploring AI-driven development on the side.
---
### Book: Mastering Genkit
**Mastering Genkit: Go Edition**
Co-authored with Xavier Portilla, AI Google Developer Expert (GDE) from Spain.
Unlock the full potential of Genkit and Go—explore the journey from fundamentals to advanced AI systems.
[Amazon](https://www.amazon.com/dp/B0G4HKJRPB) / [GitHub](https://github.com/mastering-genkit)
---
### Building
**TerraDart**
Dart now runs on both the client and the server, yet infrastructure is still written in HCL or `*.tf.json`, apart from the app that depends on it.
TerraDart is my ongoing work on type-safe IaC for that stack. Write cloud infrastructure as Dart (Google Cloud, AWS, Cloudflare, Appwrite), apply it with one command, and read the outputs in your Flutter app as typed Dart.
[terradart.dev](https://terradart.dev/) / [GitHub](https://github.com/nozomi-koborinai/terradart) / [pub.dev](https://pub.dev/packages/terradart_codegen)
**jev-spec**
Checks your code against the requirements in your Markdown specification and fails the build when they drift apart. It asks TypeSafe AI's Jev model one focused question per requirement, gets a probability back, and compares it with a threshold you set — small enough for a pre-commit hook and strict enough for a CI gate. Tagline: *"Catch spec drift on every commit."*
[GitHub](https://github.com/nozomi-koborinai/jev-spec) / [npm](https://www.npmjs.com/package/jev-spec) / [Article](/tech/jev-spec-introduction/)
**contextlint**
A semantic linter for AI-era Markdown. Verifies cross-references, required sections, and dependency graphs deterministically — across your editor, AI assistants, and CI. Tagline: *"markdownlint for syntax. contextlint for meaning."*
[contextlint.dev](https://contextlint.dev/) / [GitHub](https://github.com/nozomi-koborinai/contextlint)
**GCP Cost MCP Server**
An MCP server that lets AI assistants like Claude or Cursor estimate GCP running costs — instead of manually using the Google Cloud Pricing Calculator. Built on the Cloud Billing Catalog API with Go + Genkit. Works with conversational requirements and architecture diagrams alike. Adopted for use at the GCP partner company I work for.
[GitHub](https://github.com/nozomi-koborinai/gcp-cost-mcp-server)
**Ableton OSC MCP Server**
An MCP (Model Context Protocol) server that lets AI assistants like Claude or Cursor drive Ableton Live 11+ via AbletonOSC. Built in Go + Genkit. Tracks, MIDI clips, tempo, and clip slots are exposed as MCP tool calls so AI conversation can drive beat-making and music production directly.
[GitHub](https://github.com/nozomi-koborinai/ableton-osc-mcp)
---
### OSS Contributions
**Genkit for Dart and Flutter**
Originally co-developed the Dart client for Genkit with the Genkit team, aiming to grow the Flutter/Dart Genkit community. The project has since evolved from a client library into Genkit for Dart and Flutter — a full AI SDK with client features alongside. Now contributing as a community member.
[GitHub](https://github.com/genkit-ai/genkit-dart)
**Genkit (core)**
Small contributions to the upstream Genkit repository: docs improvements and minor PRs.
[GitHub](https://github.com/genkit-ai/genkit)
---
### Highlights
| Item | Description | Link |
|------|-------------|------|
| FlutterGakkai MVP (10th) | Best talk award at the 10th FlutterGakkai for a TerraDart presentation | - |
| Google Cloud Partner Top Engineer 2025 & 2026 | Selected for Application Modernization two years in a row | - |
| Introduction to Flutter Design Patterns (co-author) | Covers layered / onion architecture | [Zenn](https://zenn.dev/flutteruniv/books/flutter-architechture-en) |
| App University (formerly Flutter University) | Former instructor, current 1-on-1 mentor | [flutteruniv.com](https://flutteruniv.com) |
---
### Speaking & Events
| Date | Event | Title | Attendees |
|------|-------|-------|-----------|
| 2026/07 | FlutterGakkai (10th) | [Trying Type-safe IaC with Dart](https://speakerdeck.com/coborinai/type-safe-iac-for-dart) | 56 |
| 2025/07 | Google I/O Extended Tokyo 2025 | [Dart client for Genkit: Official Client for Using Genkit from Flutter](https://youtu.be/AakdczWQLzY?si=aiuon32lYmJ93TR6) | 434 |
| 2025/02 | FlutterGakkai (7th) | [Accelerating Generative AI App Development with Flutter × Firebase Genkit](https://www.youtube.com/live/bLub7ytpKBA?si=rkyc3Ca76CVDn8Wa&t=518) | 191 |
| 2024/12 | Genkit Hands-on (Organizer) | Technical supporter | 45 |
| 2024/11 | GDG DevFest Tokyo 2024 | Orchestrating Firebase and AI: Genkit Architecture Example | 369 |
| 2024/06 | TECH+ Seminar | Revolutionizing Logistics with Google Maps Platform | 246 |
| 2024/06 | Map The Way Tokyo | Retail DX Strategy for Competitive Advantage | 75 |
| 2024/04 | Retail DX | Retail DX Strategy for Competitive Advantage | 138 |
| 2024/04 | Google Cloud RetailTech Recap Seminar | Integrating Google Maps, Firebase, and PaLM | 51 |
| 2024/03 | RetailTech JAPAN | Enhancing Development Efficiency with Google Maps, Firebase, and PaLM Integration | 429 |
| 2024/02 | GDG Tokyo Monthly Online Tech Talks | IaC and CI/CD Pipeline Construction in Firebase | 200 |
| 2023/12 | Cloud Ace Webinar | Rapid Development of a Healthy Review App with Firebase × Google Maps API | 61 |
---
### Project Experience
| Year | Project | Overview |
|------|---------|----------|
| 2026 | Cloud Modernization (On-prem Alfresco → GCP) | Tech lead & PM overseeing backend and SRE engineers. Migrating on-premises Alfresco to GCP with a platform engineering mindset — building harnesses to maximize developer experience for the team |
| 2026 | GenAI External Security Harness | Solo from consulting through PM. Mapped audit policy requirements into log retention design, then delivered an external security harness using Sensitive Data Protection (DLP API), Agent Platform Gen AI evaluation service, and Model Armor |
| 2025 | Full-scratch Business System Development | Secure architecture with Cloud Run + Cloud SQL Auth Proxy. Fully automated IaC with CDKTF. Solo full-stack TypeScript development |
| 2025 | Platform Engineering Support | Supported a project struggling with dev environment differences and Firebase Authentication implementation. Standardized dev environments for 20+ engineers using Cloud Workstations and conducted Firebase Auth best practices workshops |
| 2025 | Generative AI App Development Support (Won Competition) | Proposed Genkit and DB security architecture, winning the contract over 2 competitors. Also supported team building focused on Agentic AI coding |
| 2025 | Google Cloud Next '25 Live Reporting App (Award Winner) | Developed an app using Genkit for speech-to-translation-to-draft generation. Team published 23 live reports in 3 days. Received award from Google Cloud Japan |
| 2024 | Identity & Access Management Architecture | Introduced Identity Platform as IDaaS. Proposed dynamic authorization with Cloud Run sidecar + Open Policy Agent (OPA), supporting SAML auth providers |
| 2024 | Firebase Architecture Support | Implemented IP restrictions for Cloud Functions deployment via VPC Service Controls. Designed auto-documentation pipeline with Gemini Code Assist |
| 2023 | Tokyo Flutter Hackathon Winner | Participated with App University (formerly Flutter University) team. Developed real-time online game in 24 hours. Handled Firebase full-stack (Auth, Firestore, Hosting, Cloud Storage, Cloud Functions) |
| 2023 | Cloud Management Platform Construction | Tech lead on a custom Cloud Management Platform centralizing change management and operational tasks across a customer's GCP environment. Wired Snyk IaC into the Terraform pipeline for both static IaC scanning and drift detection between Terraform state and the actual cloud |
| 2023-24 | MoodTrend | Mood tracking app for bipolar disorder. As tech lead, designed Flutter software architecture and CI/CD pipeline (Workload Identity + GitHub Actions + Terraform), contributing to initial release. Currently off the project ([iOS](https://apps.apple.com/jp/app/%E6%B0%97%E5%88%86%E3%82%B0%E3%83%A9%E3%83%95-moodtrend-%E5%8F%8C%E6%A5%B5%E6%80%A7%E9%9A%9C%E5%AE%B3-%E8%BA%81%E9%AC%B1%E3%82%B0%E3%83%A9%E3%83%95/id6480364789) / [Android](https://play.google.com/store/apps/details?id=com.moodtrend.mood_trend_flutter)) |
| 2019–2023 | Large-scale Electronic Medical Record System (4 years) | Team member on a 30+ developer codebase with 400+ tables. Incrementally migrated parts of the legacy VB.NET / C# / ASP.NET / WPF stack from tightly-coupled to loosely-coupled — introducing ViewModel / Repository layers (MVVM), replacing live network calls in unit tests with mocks |
---
### Beyond Code
When I'm not coding, I enjoy:
- 🎸 **Music**: Guitar playing and beat-making ([YouTube](https://www.youtube.com/@cobo_poly))
- 📚 **Reading**: Exploring new ideas and perspectives
- 🧖 **Digital Detox**: I value downtime in baths and saunas above all else
**Work hard, rest well.**
---
## Why koborin.ai?
- **koborinai** is my family name
- **koborin** is my nickname
- **koborin.ai** = nickname + `.ai` TLD
I frequently work with AI-related products like Genkit, and with the rise of Agentic AI, the `.ai` domain feels like a natural fit.
Feel free to explore this proving ground—I'll keep tending to it bit by bit.
---
## koborin.ai へようこそ
URL: https://koborin.ai/ja/
import SiteImage from '../../../components/SiteImage.astro';
import ThemedSiteImage from '../../../components/ThemedSiteImage.astro';
import GitHubSponsorsButton from '../../../components/GitHubSponsorsButton.astro';
import heroLight from '../../../assets/og/koborin-ai-hero-light.png';
import heroDark from '../../../assets/og/koborin-ai-hero-dark.png';
import bookCover from '../../../assets/life/reflections-on-2025/book-cover.png';
import terradartOg from '../../../assets/_shared/building/terradart-og.png';
import jevSpecOg from '../../../assets/og/jev-spec-introduction.png';
import contextlintOg from '../../../assets/_shared/building/contextlint-og.png';
import gcpCostMcpHero from '../../../assets/_shared/building/gcp-cost-mcp-hero.jpeg';
import abletonOscMcpKey from '../../../assets/_shared/building/ableton-osc-mcp-key.png';
## Technical Proving Ground について
ここは、モダンなソフトウェア開発の旅を探求し、記録し、共有するための個人的な Technical Proving Ground です。
*仮説 → 検証 → 振り返り* のサイクルを回しながら、ぼちぼち育てていきます。
---
## Who runs this
Built by [@nozomi-koborinai](https://github.com/nozomi-koborinai).
- 🏢 日本の Google Cloud パートナー企業でサーバーレスアプリケーション開発を専門に活動
- 🏆 Google Cloud Partner Top Engineer 2025 & 2026 (Application Modernization 領域で 2 年連続選出)
- 📖 Genkit の書籍・OSS・登壇を通じて開発者コミュニティに貢献
- 🎯 DDD・クリーンアーキテクチャを軸にした品質確保が得意
- 🤖 AI 駆動開発を探求中 — [contextlint](https://github.com/nozomi-koborinai/contextlint) や [jev-spec](https://github.com/nozomi-koborinai/jev-spec) などのツールを開発
Support this proving ground
### 経歴
2019 年 3 月に IT 専門学校を卒業し、Java によるオブジェクト指向プログラミングの基礎を築きました。
最初の 4 年間は、エンタープライズのレガシーシステム開発に携わりつつ、App 大学(日本のアプリケーション開発エンジニアコミュニティ)での講師活動と、Flutter デザインパターンに関する書籍の共同執筆を並行していました。
現在は Google Cloud パートナー企業で、クラウドアーキテクチャ・プリセールス・テックリードなど幅広く携わりつつ、個人で AI 駆動開発を探求しています。
---
### 書籍: Mastering Genkit
**Mastering Genkit: Go Edition**
スペインの AI Google Developer Experts (GDE) Xavier Portilla 氏との共著。
Genkit と Go の可能性を最大限に引き出し、初歩から高度な AI システムまで、その道のりを探求しましょう。
[Amazon](https://www.amazon.com/dp/B0G4HKJRPB) / [GitHub](https://github.com/mastering-genkit)
**jev-spec**
Markdown の仕様書に書かれた要件とコードを照合し、両者がずれたらビルドを失敗させるツール。要件ごとに焦点を絞った質問を TypeSafe AI の Jev モデルに投げ、返ってきた確率をしきい値と比較することで、pre-commit フックに収まる軽さと CI ゲートに使える厳密さを両立します。タグライン: *「仕様書とコードのずれを、コミットのたびに検出する。」*
[GitHub](https://github.com/nozomi-koborinai/jev-spec) / [npm](https://www.npmjs.com/package/jev-spec) / [解説記事](/ja/tech/jev-spec-introduction/)
**contextlint**
AI 時代の Markdown のためのセマンティックリンター。エディタ・AI アシスタント・CI を横断して、相互参照・必須セクション・依存グラフを決定論的に検証します。タグライン: *「markdownlint for syntax. contextlint for meaning.」*
[contextlint.dev](https://contextlint.dev/) / [GitHub](https://github.com/nozomi-koborinai/contextlint)
**GCP Cost MCP Server**
Claude や Cursor などの AI アシスタントから、Google Cloud Pricing Calculator を手動で触る代わりに GCP のランニングコストを見積もれる MCP サーバー。Cloud Billing Catalog API ベース。Go + Genkit で実装。テキストの要件整理からアーキテクチャ図の解析まで、幅広い入力に対応。所属の GCP パートナー企業でも利用実績あり。
[GitHub](https://github.com/nozomi-koborinai/gcp-cost-mcp-server)
**Ableton OSC MCP Server**
Claude や Cursor などの AI アシスタントから、AbletonOSC 経由で Ableton Live 11+ を操作できる MCP (Model Context Protocol) サーバー。Go + Genkit で実装。トラック・MIDI クリップ・テンポ・クリップスロットを MCP のツール呼び出しとして公開し、AI との対話からビートメイキングや音楽制作を直接駆動できる。
[GitHub](https://github.com/nozomi-koborinai/ableton-osc-mcp)
---
### OSS Contributions
**Genkit for Dart and Flutter**
もともと Flutter/Dart の Genkit コミュニティを広げる目的で、Genkit チームと Dart 用クライアントライブラリを共同開発。その後プロジェクトはクライアントライブラリから Genkit for Dart and Flutter — クライアント機能も備えた本格的な AI SDK へと発展。現在はコミュニティの一員としてコントリビュートを継続中。
[GitHub](https://github.com/genkit-ai/genkit-dart)
**Genkit (core)**
Genkit 本体リポジトリへの小さなコントリビューション。docs 改善や軽微な PR を中心に。
[GitHub](https://github.com/genkit-ai/genkit)
---
### ハイライト
| 項目 | 説明 | リンク |
|------|------|--------|
| FlutterGakkai 第10回 MVP | TerraDart 発表で最優秀賞 | - |
| Google Cloud Partner Top Engineer 2025 & 2026 | Application Modernization 領域で 2 年連続選出 | - |
| Flutter 設計パターン入門(共著) | レイヤード/オニオンアーキテクチャについて言及 | [Zenn](https://zenn.dev/flutteruniv/books/flutter-architecture) |
| App 大学(旧 Flutter 大学) | 元講師、現 1on1 メンター | [flutteruniv.com](https://flutteruniv.com) |
---
### 登壇・イベント
| 日付 | イベント | タイトル | 参加者 |
|------|----------|----------|--------|
| 2026/07 | 第10回 FlutterGakkai | [Dart で試す Type-safe IaC](https://speakerdeck.com/coborinai/type-safe-iac-for-dart) | 56 |
| 2025/07 | Google I/O Extended Tokyo 2025 | [Dart client for Genkit: Flutter から Genkit を使うための公式クライアント開発](https://youtu.be/AakdczWQLzY?si=aiuon32lYmJ93TR6) | 434 |
| 2025/02 | FlutterGakkai(第7回) | [Flutter × Firebase Genkit で加速する生成 AI アプリ開発](https://www.youtube.com/live/bLub7ytpKBA?si=rkyc3Ca76CVDn8Wa&t=518) | 191 |
| 2024/12 | Genkit ハンズオン(運営) | テクニカルサポーターとして参加 | 45 |
| 2024/11 | GDG DevFest Tokyo 2024 | Orchestrating Firebase and AI: Genkit architecture example | 369 |
| 2024/06 | TECH+ セミナー | Google Maps Platform で物流業界を革新!効率化とコスト削減を実現 | 246 |
| 2024/06 | Map The Way Tokyo | 競争力を高めるリテール DX 戦略 | 75 |
| 2024/04 | リテールDX | 競争力を高めるリテール DX 戦略 | 138 |
| 2024/04 | Google Cloud リテールテック振り返りセミナー | Google Maps・Firebase・PaLM の技術統合 | 51 |
| 2024/03 | リテールテックJAPAN | Google Maps・Firebase・PaLM の技術統合による開発効率と柔軟性の向上 | 429 |
| 2024/02 | GDG Tokyo Monthly Online Tech Talks | Firebase における IaC と CI/CD パイプライン構築のすゝめ | 200 |
| 2023/12 | Cloud Ace ウェビナー | Firebase × Google Maps API で健全なクチコミアプリを爆速開発! | 61 |
---
### プロジェクト実績
| 年 | プロジェクト | 概要 |
|----|--------------|------|
| 2026 | クラウドモダナイゼーション(オンプレ Alfresco → GCP) | バックエンド・SRE エンジニアを束ねるテックリード兼 PM として活動中。プラットフォームエンジニアリングを意識し、メンバーに最大限の開発者体験を提供できるようハーネス整備を推進 |
| 2026 | 生成 AI 向けセキュリティ外部ハーネス | 監査ポリシーを踏まえたログ保管設計のコンサルから PM まで単独担当。Sensitive Data Protection(DLP API)、Agent Platform Gen AI evaluation service、Model Armor を組み合わせた外部セキュリティハーネスを構築 |
| 2025 | 業務システムのフルスクラッチ開発 | Cloud Run + Cloud SQL Auth Proxy によるセキュアなアーキテクチャ。CDKTF で IaC を完全自動化。TypeScript でフルスタック開発を単独で担当 |
| 2025 | プラットフォームエンジニアリング支援 | 開発環境の差異と Firebase Authentication の実装が課題となっていたプロジェクトを支援。Cloud Workstations で 20 名分の開発環境を標準化し、Firebase Authentication のベストプラクティスに関するワークショップを実施 |
| 2025 | 生成 AI アプリ開発支援(コンペ勝利) | Genkit と DB のセキュリティ設計を提案し、競合 2 社を抑えて受注。Agentic AI コーディングに特化したチームビルディングも支援 |
| 2025 | Google Cloud Next '25 速報アプリ(アワード受賞) | Genkit で音声→翻訳→記事下書き生成アプリを開発し、チームで 3 日間で 23 本の速報記事を実現。Google Cloud Japan よりアワードを受賞 |
| 2024 | 認証認可基盤の設計・構築 | Identity Platform を IDaaS として導入。Cloud Run サイドカー + Open Policy Agent (OPA) による動的な認可制御を提案し、SAML 認証プロバイダーに対応 |
| 2024 | Firebase アーキテクチャ支援 | VPC Service Controls で Cloud Functions のデプロイに IP 制限を導入。Gemini Code Assist でドキュメント自動生成パイプラインを設計 |
| 2023 | 東京 Flutter ハッカソン優勝 | App 大学(旧 Flutter 大学)のチームで参加し、24 時間でリアルタイムオンラインゲームを開発。Firebase フルスタック(Auth, Firestore, Hosting, Cloud Storage, Cloud Functions)を担当 |
| 2023 | Cloud Management Platform 構築 | 顧客の GCP 環境横断で変更管理・運用業務を集約する Cloud Management Platform をテックリードとして構築。Terraform パイプラインに Snyk IaC を組み込み、IaC コードの静的スキャンと、Terraform state とクラウド実態とのドリフト検知の両方を実現 |
| 2023-24 | 気分グラフ MoodTrend | 双極性障害向けの気分記録アプリ。テックリードとして Flutter のソフトウェアアーキテクチャ設計と CI/CD パイプライン構築(Workload Identity + GitHub Actions + Terraform)を担当し、初回リリースに貢献。現在はプロジェクトから離脱 ([iOS](https://apps.apple.com/jp/app/%E6%B0%97%E5%88%86%E3%82%B0%E3%83%A9%E3%83%95-moodtrend-%E5%8F%8C%E6%A5%B5%E6%80%A7%E9%9A%9C%E5%AE%B3-%E8%BA%81%E9%AC%B1%E3%82%B0%E3%83%A9%E3%83%95/id6480364789) / [Android](https://play.google.com/store/apps/details?id=com.moodtrend.mood_trend_flutter)) |
| 2019–2023 | 大規模電子カルテシステム開発(4 年) | 30 名以上の開発体制・400 を超えるテーブル規模のコードベースにチームメンバーとして参画。VB.NET / C# / ASP.NET / WPF というレガシースタックを、密結合から疎結合へ少しずつ移行 — MVVM の ViewModel / Repository レイヤーを導入し、単体テスト内に直接入っていたネットワーク呼び出しをモックに置き換え |
---
### プライベート活動
コードを書いていないときは、以下を楽しんでいます:
- 🎸 **音楽**: ギター演奏とビートメイキング([YouTube](https://www.youtube.com/@cobo_poly))
- 📚 **読書**: 新しいアイデアや視点の探求
- 🧖 **デジタルデトックス**: お風呂やサウナでゆっくり過ごす時間を何よりも大切にしています
**よく働き、よく休む。**
---
## なぜ koborin.ai?
- **koborinai** は私の苗字
- **koborin** はニックネーム
- **koborin.ai** = ニックネーム + `.ai` ドメイン
Genkit をはじめ AI 系のプロダクトを触ることが多く、最近は Agentic AI の流れもあって `.ai` ドメインがしっくりきています。
ぼちぼち更新していくので、気軽に覗いてみてください。
---
## The Distraction of Anxiety, the Consumption of AI
URL: https://koborin.ai/life/anxiety-as-distraction-ai-as-consumption/
import SiteImage from '../../../components/SiteImage.astro';
import headerImage from '../../../assets/og/anxiety-as-distraction-ai-as-consumption.png';
Co-authoring a technical book in English, having articles recognized by international tech communities and featured on official channels, and collaborating with engineers worldwide on open-source software.
Looking back at the past year or two, I should feel certain that I have moved forward compared to who I used to be.
Objectively speaking, there are milestones I have steadily built up.
And yet, the obsessive thought whispering inside me—"This is still not enough," "I must be recognized more"—refuses to vanish no matter how many achievements I accumulate.
I have a strong desire to work at a software company abroad in the future.
Because of that, a burning sense of restlessness constantly pushes against my back: "If I don't break through and stand out far more, I will never break out of my current environment."
It was right around this time that I picked up [*The Ethics of Leisure and Boredom*](https://www.shinchosha.co.jp/book/103541/) (*Hima to Taikutsu no Rinrigaku*) by philosopher Koichiro Kokubun.
As I turned the pages, I felt the endless anxiety I grapple with every day, along with the deep unease I feel toward how we interact with technology in the AI era, take on sharp, distinct contours.
## Hunting Rabbits Named Anxiety
I am constantly terrified of the future.
To dispel that fear, I feel I must be perfect.
So, driven by impatience, I keep catching up on technology even on weekends, without rest.
I do this because I believe that if I keep accumulating knowledge, the day will eventually come when my future self no longer has to carry any anxiety.
Yet reading Pascal's discussion of "diversion" in the book struck me to the core.
Pascal observes that human beings cannot bear to sit quietly in a room. That is why they set out on rabbit hunts to capture prey.
The crucial point here is that hunters do not genuinely desire the rabbit itself.
If someone simply handed them a rabbit, they would not be satisfied.
What they actually need is the time spent passionately running in pursuit. By throwing themselves into the chase, they continually avert their eyes from the boredom and the sense of their own nothingness that strike the moment they stop.
Isn't my relentless catch-up on technology doing the exact same thing?
I thought I was running to eliminate anxiety about the future.
In truth, unable to endure the "nobody" I face whenever I stand still, or the boredom that follows, I may simply be numbing my anxiety by the very act of running.
Psychology has the term "hedonic treadmill" (hedonic adaptation).
No matter how much we desire a particular environment or achievement, once we attain it, we quickly adapt and regress to our baseline level of happiness—or dissatisfaction.
The moment I finished co-authoring the book in English, and the moments my work was recognized in international communities, I felt genuine joy.
Yet that relief evaporated in an instant, quickly overtaken by the recurring obsession: "This is not enough; I cannot stay in the same place."
Even if I were to reach some mythical sanctuary completely liberated from every anxiety, what awaits there might not be peace at all, but the most unbearable fate for a human being: boredom.
...At an intellectual level, I can comprehend this philosophical framework.
Yet speaking honestly, reality is never forgiving enough to be settled by clean platitudes like "living with anxiety is what makes us human."
Physically and emotionally, the stress of this anxiety is intensely uncomfortable and hard to endure.
Long before I fear the boredom of an anxiety-free future, I want to shave away the acute discomfort tightening in my chest right now, as quickly as possible.
And so, unable to bear that discomfort, I find myself spending my precious weekends writing code and chasing after new technologies yet again.
## Unease at "Consuming" AI Tokens on the Ground
This impatience has created an even more tangled distortion alongside the rapid rise of AI.
In the day-to-day reality of work, we operate under rigid constraints of cost, timelines, and budgets.
When delivering a system to clients is the highest priority, there is no luxury to leisurely unravel underlying mechanisms.
What is demanded is speed and quality comparable to AI, and as a result, delegating tasks to AI by burning tokens—even while bypassing our own understanding—takes precedence.
Particularly within an organization, from a short-term perspective, an engineer who uses AI to bypass deep comprehension and quickly deliver "something that works" is often evaluated far more favorably.
Yet as a software professional, when I let something pass simply because "it seems to work," the underlying guilt and chill down my spine cannot be ignored.
Over the long haul, when critical incidents occur or when pivotal architectural decisions must be made, the engineers who bring true velocity and genuine productivity are those who understand the foundations and internal mechanics from low levels to high abstractions.
And yet, the immediate pace and evaluation metrics of the organization do not afford the time required for that understanding.
Bypassing comprehension, burning AI tokens, and merely receiving "something that miraculously runs"—this practice does little more than pile up "understanding debt," churning out little more than the disposable digital waste seen across the web.
Drawing on Jean Baudrillard's theory of the consumer society, *The Ethics of Leisure and Boredom* illuminates the vital difference between "consumption" and "waste" (or luxury).
"Consumption" does not mean savoring an object for what it is; it means endlessly swapping through newly appearing signs, trends, and superficial differences.
Because signs possess no substance, no matter how many times we exchange them, we are never satisfied, trapped in perpetual hunger.
Rushing to keep up with delivery deadlines and burning AI tokens while tossing away deep understanding: this was precisely the state of having technology "consumed" out from under me.
I pay money, consume tokens, and nothing remains in my head.
Continuing to work this way sparked a genuine fear that the very value of my existence as an engineer was being hollowed out.
## "Wasteful" Immersion to Expand the Brain's Index
On the other hand, the remedy for boredom presented in the book was reclaiming the human capacity for "waste" (luxury)—in the sense of generous immersion.
"Waste" means receiving an object fully and savoring it to completion.
To truly enjoy something, one needs knowledge, language, and the discipline (cultivation) required to receive it.
Just as one refines their palate to appreciate delicate culinary flavors, savoring something deeply has an inherent limit—a true satisfaction—and embodies a distinctly human richness.
For a long time, far more than standard application feature development, I have felt a deep passion for the less glamorous layers: platform infrastructure, developer experience, and quality engineering.
Understanding the mechanisms running beneath the surface—protocols, infrastructure, type systems, and architectural philosophies—brings an intellectual joy that nothing else matches.
From a short-term, hyper-efficiency mindset, digging into the mechanics behind code that AI could write in seconds might look like an unnecessary detour.
Yet moving to Tokyo, in part to easily attend various tech conferences and study groups, was driven by this exact desire: to expand my "brain's index."
As long as we live as human beings, the deciding factor is how many diverse, deep indexes—cabinets of inquiries—we can establish in our minds.
When confronting unknown domains where we don't even know what we don't know, without an internal index, we cannot even formulate the right questions to ask AI.
To enrich this index, I want to learn computer science from the ground up once again, and especially in this era dominated by AI, I firmly believe that taking the time to write code by hand remains indispensable.
Instead of swallowing AI outputs whole and consuming them, we can chew on those outputs and conceptual models through dialogue and reading, weaving them into our own accumulated experiences.
In modern times, that is the most luxurious "waste of time," an intellectual adventure.
Refusing to merely consume technology in an efficiency-obsessed world, and instead taking the time to savor it deeply—only through that patient accumulation of understanding can our unique individuality and humanity as engineers emerge.
## Living with the Discomfort of Anxiety, Refusing to Stop Understanding
Even if I eventually succeed in working at an overseas software company, this anxiety and impatience will likely never vanish cleanly.
Once one goal is met, another deficiency will present itself, and I will inevitably push myself with "Still not enough."
While I yearn for a tranquil life devoid of anxiety, if I were to actually arrive in a world where all anxiety had vanished, what waited for me might well be the most unbearable boredom, stripped of the feeling of being truly alive.
That is not to say I intend to act like a wise elder and declare, "Let us love our anxiety."
Anxiety remains deeply uncomfortable, stressful, and something I would escape immediately if given the chance.
Even so, I refuse to choose a path where I jump at cheap diversions just to run from that discomfort, living on the shallow "consumption" of technology.
If anxiety stirs in my chest, I will sit at my desk and face it, discomfort and all.
Even as I feel swept along by the sheer velocity of AI, I will dig in my heels, write code by hand, relearn fundamental computer science, and refuse to surrender the luxury of savoring technology to the fullest.
Looking back, setting aside efficiency and immediacy to read this book carefully and confront my own emotions was, in itself, the first luxurious "waste" I had savored in a very long time.
Burning through the diversion of anxiety, yet refusing to stop understanding.
In an age where everything is so rapidly consumed, I believe that is the only true form of sincerity I have to remain myself.
---
## Everything Has Meaning
URL: https://koborin.ai/life/everything-has-meaning/
import SiteImage from '../../../components/SiteImage.astro';
import everythingHasMeaningOg from '../../../assets/og/everything-has-meaning.jpeg';
There's something I often share in one-on-ones and conversations with team members.
"Everything has meaning," I tell them.
This comes from my own experience.
## My Past Self
As of January 2026, I'm in my seventh year as a working professional.
I spent about four years at my first company, and I'm now in my third year at my current one.
At my first company, I believed that programming was my happiness, and that mastering it was the only thing that mattered.
Design patterns, clean architecture, language specifications.
I thought that's why companies had separate sales and engineering departments—so I could live purely in the world of technology.
I left my first job because I felt like "there was nothing left for me to do."
The work involved maintaining a system that had been running for over 15 years.
It was written in VB.NET, with tightly coupled UI and logic. Due to language and framework constraints, achieving proper separation of concerns was nearly impossible.
Still, I tried my best. For new features, I worked on improving testability.
But I had a growing desire to "learn software architecture from scratch," so I sought a new environment and changed jobs.
Looking back, my narrow perspective was the real cause.
If I had truly wanted to learn, I could have done more to improve that system.
"I want to see a new world" might have just been an excuse to move myself to a more comfortable place.
But even if it was running away, I gained a lot from where I landed.
In my current environment, I'm handling work that would typically be assigned to someone with over 10 years of experience at my previous company.
I don't regret believing it was the right choice and taking that leap.
## At My Current Job
At my current job, I do pre-sales work.
I work with sales to help win projects for our company.
The process involves understanding the customer's challenges and proposing how our technology can help. If the proposal lands, we get the contract.
But no matter how strong your programming skills are, that alone won't get you far.
In one pre-sales meeting, I proposed implementing DevOps.
The audience was leadership from a technical department struggling with development resources.
I confidently presented case studies showing how Netflix and Amazon had improved their development productivity.
But their reaction was cold.
"We're not a big company like that. This isn't realistic for us."
I had planned to follow up with a customized approach for their specific situation. But they cut me off with "That's enough."
I thought I was presenting a proposal that met them where they were.
But from their perspective, it was a story where they couldn't imagine their own company succeeding.
That's when I learned what it really means to fail at meeting someone where they are.
If the decision-maker is purely technical, technical discussions work.
But more often, they're not.
They're from accounting, HR, or other business-side departments unrelated to IT.
Naturally, their challenges are framed in business terms, not technical ones.
Talking about "clean architecture" or "language specifications" won't resonate.
Ultimately, technology is the "how," but first you need to understand the "what."
You need to listen at a higher level of abstraction and propose solutions that address business needs.
Later, I realized that this constant movement between "concrete and abstract" was deeply connected to my engineering work.
## The Moment Things Connect
When there's extra time in one-on-ones, or when I'm working on presentation materials with junior colleagues who are about to bloom, I share this thought:
"You know what I pay attention to in slide decks? Line spacing, color usage, how information is organized when you step back and look at the whole page.
If the presentation is supplementary to verbal communication, cramped text won't get the message across.
When using bullet points, I check if they're MECE. And that's the same structure as code design."
The same applies when splitting modules in software engineering.
Separating redundant classes, extracting common logic.
The perspective of "how to organize things so they communicate clearly" is the same whether it's a document or code.
:::note[Side note]
I don't use AI image generation much for slide creation yet. There's too much "gacha" randomness—fine-tuning nuances is difficult. Instead, I use an OSS tool called [deck](https://github.com/k1LoW/deck). You write content in Markdown and adjust the design in Google Slides. By balancing automation and manual control, it becomes easier to translate my thoughts into slides, and what I want to communicate gets organized in my head.
:::
### Coding Agents and Default Arguments
There's something I often hear from SRE team members in one-on-ones.
When they're implementing Terraform code with a coding agent, the agent tends to add default arguments.
I worked on hospital-related system development at my previous job, so I'll share a slightly modified example based on that experience.
```typescript
// Before: Two services with similar logic
class HospitalService {
async sendNotification(patientId: string, message: string) {
// Logic running in 100 hospitals
}
}
class ClinicService {
async sendNotification(patientId: string, message: string, priority: string) {
// Logic under new development
}
}
```
Ask a coding agent to refactor, and you might get this:
```typescript
// After: Agent "safely" consolidates with default argument
class NotificationService {
async sendNotification(
patientId: string,
message: string,
priority: string = "normal" // Default to avoid impacting existing code
) {
// Consolidated logic
}
}
```
No errors. But poor maintainability.
Systems have context that isn't explicitly documented.
For logic running in 100 hospitals, it affects human lives. Keeping a default to avoid touching that logic might make sense.
But for systems under development, it's different.
When you haven't released yet and you're still in the system design phase, it's often better for callers to specify values with intent.
The same applies to infrastructure. In Terraform, when you have a module called from both dev and prod, relying entirely on the module's default values means dev/prod don't pass values explicitly. Resource definitions meant for dev/prod get hidden inside the module's defaults.
You need to understand the context—whether it's business-driven or technical—and make deliberate decisions.
And those decisions must have reasons behind them.
---
What I learned from pre-sales, what I pay attention to in presentation design, what I think about in code design.
They seem like separate topics, but they all share the same structure: moving between concrete and abstract.
At my first company, I only looked at the "concrete."
That's why I felt like "there was nothing left for me to do."
But the opportunity to learn abstraction through that concrete work was always there.
Sometimes I'm frustrated with how my current organization is structured.
But I believe that everything I do within it has meaning, and I keep going.
So in one-on-ones and conversations with team members, I tell them this:
"Even things that seem unrelated will connect someday."
Of course, I'm still learning too.
But I believe it's better to put thoughts into words while they're still fresh.
---
## Keep Building Developer Platforms
URL: https://koborin.ai/life/keep-building-developer-platforms/
import SiteImage from '../../../components/SiteImage.astro';
import keepBuildingOg from '../../../assets/og/keep-building-developer-platforms.png';
import contextlintSlack from '../../../assets/life/keep-building-developer-platforms/contextlint-slack.png';
import terradartAlphaPost from '../../../assets/life/keep-building-developer-platforms/terradart-alpha-post.png';
import terradartTalk from '../../../assets/life/keep-building-developer-platforms/terradart-talk.png';
import terradartArchitecture from '../../../assets/life/keep-building-developer-platforms/terradart-architecture.png';
Watching how fast Agentic AI has grown, and feeling its power, I decided to think about what I want to be as an engineer (a software engineer in particular) from here.
Here is roughly what my day job looks like at the moment.
I sketched it out by picturing a typical week.
- 1-on-1s with five team members
- Pre-sales, as an SIer trying to win projects
- PM and tech lead on two projects
- Maintenance and operations on one project
Every one of these has been good experience for me.
Among them, the one that fires me up the most is thinking about DX (developer experience).
## Why I Built contextlint
Lately I write requirements, specs, and designs in Markdown, treat them as the source of truth, and have AI write code from them.
When you work this way, broken documents lead to code that inherits the damage.
In practice, though, keeping those documents consistent with each other turned out to be hard.
When a requirement ID is referenced from another file, deleting or renaming that ID breaks the reference.
No error is raised, and no test fails.
The more files there are, the less doing this by hand keeps up.
Handing the consistency check to an LLM is one option.
But for questions that resolve to yes or no, like whether an ID is duplicated or whether a linked file exists, getting a different answer on every run is awkward to work with.
You end up verifying it yourself anyway, and the more documents you have, the more tokens and time it costs.
Anything that can be decided mechanically once the conditions are fixed should go to static analysis, so that human judgment is spent elsewhere.
That is what made me start building contextlint.
There is also a push for AI-driven development inside the company, so I introduced contextlint, which I build on my own time.
It went over reasonably well, and quite a few people started using it.
## Why I Am Building TerraDart
Another side project of mine is TerraDart.
Like CDK for Terraform or Pulumi, it lets you describe infrastructure in an application language.
I had been writing CDK for Terraform in TypeScript, and it was a shock when the project was archived in December 2025.
I was doing full-stack development in TypeScript at the time, so there was no switching cost between languages, it was easy to pick up, and I did not have to pull another dependency into the codebase.
On top of that, I could mirror the application's directory structure, which kept the infrastructure code easy to follow.
I liked it for those reasons, which made the news land harder.
I have heard that HashiCorp is concentrating its resources on Terraform HCL itself.
If that is the case, an option for writing infrastructure in an application language may not come from upstream.
Meanwhile, Dart is a language I enjoy as a hobby and like the feel of writing, so I started building a Dart version of CDK for Terraform for Flutter and Dart developers.
Flutter is reaching past mobile and web frontends into the backend, with Genkit Dart approaching GA and a Dart version of Cloud Functions for Firebase now available.
As for infrastructure, Flutter engineers are probably on Firebase or GCP more often than not.
My guess is that they reach for either the console or Terraform.
So I am building this with real enthusiasm, for full-stack Dart developers.
In August 2026, [the update from pre-alpha to alpha was completed](https://x.com/fender_kn/status/2088570690559742424).
I implemented the Terraform Google provider in Dart.
Integrations with Cloudflare and other third-party tools are on my list next.
The slides are up as [Type-safe IaC for Dart](https://speakerdeck.com/coborinai/type-safe-iac-for-dart).
## The Words Change, the Ideas Accumulate
In the AI era, new models ship almost daily, tools ship alongside them, and the ecosystem is expanding at tremendous speed.
In a situation like that, maybe what is needed is to keep the day-to-day environment in order for developers.
And to go in ready to throw that away tomorrow.
Which is why following the broad technical trends while understanding the concrete tools, that relationship between the abstract and the specific, still matters.
Even now there is a run of terms, starting with "prompt engineering" and continuing into "loop engineering" and "graph engineering" (as of August 2026).
Look closely at them, though, and more than a few feel like practices from our waterfall and agile days showing up under new names.
The tools themselves get swapped out, but the thinking underneath them accumulates.
Hold on to what you already know as the broad outline, and pick whatever fits best at the time.
## Building Anyway
At FlutterGakkai #10, where I gave a talk, one of the other speakers said something like this.
In the AI era, you find a niche problem and build against it with more passion than anyone else.
Wielding your madness as a developer, so to speak.
For people working alongside Agentic AI, the switching cost of technology is still heavy.
And the layer where judgment happens keeps moving up in abstraction.
That judgment, in turn, leans on the domain.
The closer you get to the domain, of course, the more you run into knowledge that no LLM was trained on.
To think that through properly, the switching cost on the technical side has to be minimal, and the cognitive load has to stay within what both humans and AI can bear.
Through all of that, it matters that engineering itself stays interesting and fun, and that is part of what developer experience means to me.
Deepening domain understanding is a given, but it cannot come at the price of engineering as a whole becoming dull because an LLM does it.
Keeping that alive may be what software engineers (platform engineers, SREs, and others) will need to care about going forward.
Making developer experience better takes a foundation underneath it.
Hide the technical complexity behind a black box, and keep it safe.
That is the "developer platform" I mean, in the broadest sense, and I want to keep building it, with the nerve to let some things go and take other things on.
---
## Reflections on 2025
URL: https://koborin.ai/life/reflections-on-2025/
import SiteImage from '../../../components/SiteImage.astro';
import nextImage from '../../../assets/life/reflections-on-2025/next.png';
import tourImage from '../../../assets/life/reflections-on-2025/office-tour.jpeg';
import dmImage from '../../../assets/life/reflections-on-2025/dm.png';
import bookCoverImage from '../../../assets/life/reflections-on-2025/book-cover.png';
import reflectionsOn2025Og from '../../../assets/og/2025.png';
2025 was a turbulent year for me—someone who had mostly stayed at home until now.
First trip abroad, a major life stage change, co-authoring a book with an engineer from overseas, publishing it, and getting promoted at my main job.
Through all of this, my mental model has been shifting significantly. I'm writing this blog to organize my thoughts.
## 1. Google Cloud Next '25
This year, I attended [Google Cloud Next '25](https://cloud.withgoogle.com/next/25), the celebration of GCP technology.
At the time, I was interested in serverless products, so I got to talk with Google engineers working on Cloud Run, Identity Platform, and Firebase products.
Originally, I planned to give them a feature request for their product.
But when I was actually standing in front of them, I couldn't say anything.
I was still learning English, and I couldn't get even a fraction of what was in my head into words.
I just stood there in silence. It was frustrating. Embarrassing, really.
But I couldn't hide my excitement at meeting the product manager of products I use and love every day. In the end, all I could say was "I love your product!"
Still, maybe that feeling got through. At least, I want to believe it did.
I was happy that [Dmitry Lyalin](https://www.linkedin.com/in/dmitrylyalin/), who worked on Google's AI development framework [Genkit](https://genkit.dev/) and now leads the Gemini CLI product, seemed to sense something from my broken English.
"Improving developer experience is my greatest joy and motivation."
I desperately tried to convey this using a translation app.
That if I can contribute to OSS and spread technical articles to let engineers around the world know about these technologies, I'd be happy.
Then I was introduced to [Sumit Chandel](https://www.linkedin.com/in/sumit-chandel-a273792/), a DevRel based in Japan, and after returning home, I was even invited to an office tour.
I realized that even if you can't speak the language well, you can still connect through emotion.
After that, my passion didn't cool down, and I kept contributing to [Medium](https://medium.com/@nozomi-koborinai) and the [Awesome list](https://github.com/xavidop/awesome-genkit).
## 2. Co-authoring a Book
Then I got a direct message from [Xavi Portilla](https://www.linkedin.com/in/xavierportillaedo/), a GDE (Google Developer Experts) in the AI field based in Spain, asking if I wanted to write a book about Genkit together.
The OSS world is really fascinating. I never expected it to lead to encounters and activities like this.
At the time, only the JavaScript (TypeScript) version of Genkit was GA, but we decided to release `Mastering Genkit: Go Edition` ahead of Genkit for Go, which was scheduled for GA in late October 2025. I learned a lot from a promotional perspective too.
The time difference between Spain and Japan is about 7 hours. For about 4 months, I had days where I significantly cut into my sleep.
Honestly, there were many tough moments. But for me, living as an engineer, it was even more fun than it was hard.
I learned so much from Xavi.
Especially "the attitude of taking time to deliver something good to people."
No need to rush—when there are big changes, taking time leads to delivering better results.
He gave me very straightforward advice on the structure of what I wrote and how to structure it as an English book.
[Mastering Genkit: Go Edition](https://www.amazon.com/dp/B0G4HKJRPB)
In an era where AI keeps advancing, I think we should put our own thinking and philosophy into what we create.
Through co-authoring with Xavi, I was able to practice that, and getting it out into the world was a good experience.
## 3. Work
There was a big change at work too.
Since this year, I've been leading a team of 4 people.
I set aside 1 hour every week for 1-on-1s to listen to my team members.
This isn't about giving technical direction as a development lead or project manager.
Team members are assigned to different company projects, so they're basically scattered.
That's exactly why I think there needs to be a place to talk about mental health, everyday work concerns, what technical areas they want to pursue, what career paths they want to choose, and what projects they want to be involved in.
The team includes designers, engineers who want to focus on frontend, and engineers aiming for SRE.
I'll say it again—no matter how much AI advances, I don't think human dialogue will disappear.
Through conversations with people, my mental model gets strengthened, and I started thinking about where both myself and the other person would feel most comfortable, and what kind of world I should show them for their growth.
Before I knew it, something like parental affection had sprouted in me. (Somehow.)
People have environments they've lived in for decades, and those environments are reflected in their humanity. That's why conversations with people are interesting.
I also receive perspectives that others take for granted, and I feel like I'm learning about how people relate to each other more than I did in my early 20s.
And when I put what I've experienced into words and share it with them, they try to understand—even if they don't fully get it.
That makes me happy, even if it's a bit selfish of me.
I hope we can keep influencing each other and grow both as engineers and as human beings.
## 4. Community
There's an engineer community called [Flutter University](https://flutteruniv.com/).
I've been a member for over 3 and a half years now.
I'm involved in some operations like being a 1-on-1 mentor for new members, but basically I participate as a regular member.
I post in my personal channel, spread knowledge from other communities like Genkit to Flutter, and occasionally speak at events run by Flutter University.
This community is mostly engineers from different companies.
It's relatively easy to consult about my own concerns, and if there's an interesting event, I can invite people or get invited—it's very active.
Right now, I'm working on a project to build an AI agent application.
I felt that the engineering world is vast because I joined this community.
Deciding to move to Tokyo, landing a fulfilling job after changing companies, enjoying private OSS activities, and the book authorship—all of it happened because of this community.
In 2026, Flutter University is going through a rebranding, and I want to contribute to its growth as a way of giving back.
## 5. The Person Who Supported Me
This year, there was a major life stage change.
There's someone who supported my challenges from the closest distance.
I can't go into too much detail, but at Google Cloud Next, when I could barely speak or understand English, they stood between me and Google engineers at the Firebase booth to interpret.
During the 4 months when I was cutting into my sleep for the book, they understood my personality of "not being satisfied until I reach a good stopping point" while still caring most about my health.
They stayed by my side even knowing my struggles and my weird obsessions.
I can't express how much I've been supported. I'm always grateful.
## Closing
2025 is just the starting point for building my career going forward.
The me who can't speak English, the me who cuts into sleep to immerse myself in work, the me who's awakening to parental feelings toward team members, the me who realizes the importance of the person supporting me from beside.
This was a year of discovering that version of myself.
In 2026, I want to reach even higher. In my private life and work, as an engineer and as a person, I want to face even more people and environments.
---
## Privacy Policy
URL: https://koborin.ai/privacy-policy/
Last updated: January 6, 2025
## Overview
This privacy policy explains how koborin.ai ("we", "us", or "our") collects, uses, and protects your information when you visit our website.
## Data We Collect
### Analytics (Google Analytics 4)
We use Google Analytics 4 to understand how visitors interact with our website. This service collects:
- Pages you visit and time spent on each page
- Your approximate geographic location (country/city level)
- Device and browser information
- Referral source (how you found our site)
This data is anonymized and used solely to improve our content and user experience. Google Analytics uses cookies to collect this information. You can opt out of analytics tracking by declining cookies when prompted.
For more information, see [Google's Privacy Policy](https://policies.google.com/privacy).
### Comments (Giscus)
Our comment system is powered by [Giscus](https://giscus.app/), which uses GitHub Discussions. When you leave a comment:
- You authenticate through your GitHub account
- Your GitHub username and avatar are displayed with your comment
- Comments are stored in our GitHub repository's Discussions
We do not have access to your GitHub password. For more information, see [GitHub's Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement).
## Cookies
We use cookies for the following purposes:
| Cookie | Purpose | Duration |
|--------|---------|----------|
| `cookie_consent` | Remembers your cookie preference | 1 year |
| `_ga`, `_ga_*` | Google Analytics tracking (only with consent) | 2 years |
You can manage your cookie preferences at any time by clearing your browser's cookies or using your browser's privacy settings.
## Your Rights (GDPR)
If you are located in the European Union, you have the right to:
- Access the personal data we hold about you
- Request correction of inaccurate data
- Request deletion of your data
- Object to data processing
- Withdraw consent at any time
To exercise these rights, please contact us using the information below.
## Third-Party Services
We use the following third-party services:
- **Google Analytics** - Web analytics
- **Giscus** - Comment system (via GitHub)
Each service has its own privacy policy governing their data practices.
## Data Retention
- Analytics data is retained according to Google Analytics' default settings
- Comments remain until deleted by the author or repository owner
- Cookie consent preferences are stored locally in your browser
## Changes to This Policy
We may update this privacy policy from time to time. Significant changes will be noted with an updated "Last updated" date at the top of this page.
## Contact
If you have questions about this privacy policy, please reach out via:
- Email: [kobofender@gmail.com](mailto:kobofender@gmail.com)
---
## Building contextlint: A Static Analysis Linter for Markdown Document Consistency
URL: https://koborin.ai/tech/contextlint-introduction/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import mcpImage from '../../../assets/tech/contextlint-introduction/mcp-en.png';
import contextlintIntroductionOg from '../../../assets/og/contextlint-introduction.png';
> **Updated 2026-04-29**: contextlint v0.9 ships with editor integration via LSP and a VS Code extension — see the [follow-up article](/tech/contextlint-lsp-and-editor-integration/) for the details.
## Introduction
In AI-powered software development, an approach is taking hold where structured documents — requirements, specifications, and designs — are managed as an SSOT (Single Source of Truth) and AI generates code based on them. This is commonly called **Specification-Driven Development (SDD)**.
In this approach, the quality of documents directly impacts the quality of generated code.
In other words, **if documents are broken, the code produced from them will be affected too**.
I practice SDD-based document management in my work, and through that process, I've faced the challenge of "keeping Markdown documents consistent with each other."
This article introduces **contextlint**, a tool I'm building to solve that challenge.
## Document Structure in SDD
### Layered Document Hierarchy
I organize documents in layers based on their **rate of change**.
Specifically, I use a 3-layer structure:
```text
docs/
├── foundation/ ← Layer 1: Near-immutable (glossary, etc.)
│ └── glossary.md
├── standards/ ← Layer 2: Low frequency (management rules)
│ └── doc_standards.md
└── zones/ ← Layer 3: High frequency (requirements, specs, designs)
├── auth/
│ ├── overview.md
│ ├── requirements.md
│ ├── spec_session.md
│ ├── table_users.md
│ └── screen_login.md
└── todo/
├── overview.md
├── requirements.md
├── spec_task.md
├── table_tasks.md
└── screen_task_list.md
```
Layer 1 holds project-wide ubiquitous language, Layer 2 holds document governance rules, and Layer 3 holds the actual requirements, specifications, and designs.
### Zones as Boundaries of Responsibility
A **Zone** is a unit of responsibility boundary. For example, `auth` (authentication) and `todo` (task management) each have their own document sets for independent concerns.
Dependencies between zones are explicitly managed.
### Traceability from Requirements to Code
The benefit of SDD is the traceability chain: **requirements → specifications → design → code**.
```text
requirements.md (REQ-TODO-01)
├── spec_task.md (Task CRUD specification)
│ ├── screen_task_list.md (List screen field definitions)
│ └── table_tasks.md (tasks table column definitions)
│ └── prisma/schema.prisma
└── src/todo/domain/task.ts (// REQ-TODO-01)
```
The requirement ID `REQ-TODO-01` is referenced in specification files, specifications are reflected in table and screen designs, and everything can be traced end-to-end down to the code.
## When Document Consistency Breaks
As documents grow, **maintaining consistency becomes difficult**.
### Complexity of Stability Management
In client-facing projects, you often handle requirements definition yourself.
As requirements solidify through hypotheses, demos, and feedback cycles, you need to track the status of each item.
```markdown
| ID | Requirement | Stability | Basis |
| ----------- | ----------------------------------- | --------- | ------------------ |
| REQ-TODO-01 | Create, edit, and delete tasks | stable | Agreed at kickoff |
| REQ-TODO-02 | Set deadlines on tasks | review | Pending demo check |
| REQ-TODO-03 | Categorize tasks | draft | Hypothesis stage |
```
You manage a lifecycle of `draft` (hypothesis) → `review` (awaiting confirmation) → `stable` (agreed), but it's easy to miss when a stability column is blank or contains an unexpected value.
### Fragility of Cross-References
It's common to reference requirement IDs from other files.
```markdown
## Requirements (What)
| ID | Requirement | Stability |
| --------------- | ----------------------------------- | --------- |
| REQ-TODO-TBL-01 | Manage task metadata | review |
```
When this ID is referenced from another file, deleting a requirement or changing an ID **breaks the reference**. And this breakage is **silent**.
No errors are thrown, and no tests fail.
### Inheriting Ubiquitous Language
I inherit Layer 1's ubiquitous language at the top of every SDD file.
```markdown
## Inheritance
- [Layer 1: Glossary](../../foundation/glossary.md)
- [Layer 2: Document Standards](../../standards/doc_standards.md)
```
Whether these links are valid (whether the files exist, whether the paths are correct) becomes harder to verify as the number of files grows.
### Document Proliferation
As systems grow, zones and files multiply. When requirement IDs number in the dozens or hundreds, tracking cross-references by hand is no longer feasible.
## Why Static Analysis, Not LLMs
Delegating consistency checks to an LLM is certainly an option. However, several concerns led me to settle on **static analysis**.
### Time Cost and Financial Cost
To have an LLM verify documents, you need to include all target files in the context.
As documents grow, token counts increase, driving up both **time cost** and **financial cost**.
When organizations contract Cursor or Claude Code, request limits are often shared across the team.
Consuming LLM requests for repetitive tasks like document consistency checks is something you'd rather avoid.
### LLMs Are Not Suited for Reproducible Verification
LLM outputs are inherently non-deterministic. For **yes/no problems** like "does a required column exist?", "are IDs unique?", or "does the linked file exist?" — receiving a probabilistic "it's probably fine" still leaves you needing to verify it yourself.
Being subject to this instability in AI outputs on every check leads to **decision fatigue**.
For things that can be mechanically determined as OK/NG given defined conditions, let static analysis handle it and keep human judgment to a minimum.
For verification that demands reproducibility, having an error message that clearly states "consistency is broken" is far more trustworthy.
### A Tool for AI, But Not Dependent on AI
contextlint is a tool for **verifying AI-oriented structured documents** like those in SDD, but contextlint itself is a pure static analysis tool written in TypeScript that **has zero dependency on AI / LLMs**.
AI "writes," contextlint "checks consistency."
By separating the responsibilities of generation and checking, randomness is kept out of the checking results.
### Strengths of Static Analysis
| Aspect | LLM | Static Analysis |
| --------- | ------------------------------------ | -------------------------------- |
| Stability | Results may vary between runs | Same input always yields same output |
| Speed | Slows as token count grows | Completes in seconds |
| Cost | Consumes API / request quota | None. Run freely in CI |
This is the motivation behind building `contextlint`.
## What Is contextlint
`contextlint` is a rule-based linter that verifies consistency across Markdown documents.
It detects broken cross-references, duplicate IDs, missing sections, and structural issues **deterministically, in seconds, and CI-friendly**.
```bash
npm install -D @contextlint/cli
```
It currently provides 6 categories and 21 rules.
| Category | Overview | Rules |
| ----------------------------- | ----------------------------------------------------- | ----- |
| Table Validation (TBL) | Required columns, empty cells, value constraints | 6 |
| Structure Validation (SEC / STR) | Required sections, section order, required files | 3 |
| Reference Validation (REF) | Links, IDs, anchor consistency across documents | 6 |
| Checklist Validation (CHK) | Checklist item completion status | 1 |
| Context Validation (CTX) | Placeholder detection, term consistency | 2 |
| Graph Validation (GRP) | Traceability chains, circular refs, orphan documents | 3 |
Here's how the challenges map to contextlint rules:
| Challenge | contextlint Rules |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Stability management | TBL-002 (empty cell prevention), TBL-003 (allowed value constraints) |
| Cross-reference fragility | REF-002 (ID traceability), TBL-006 (ID uniqueness) |
| Ubiquitous language links | REF-001 (link target existence), REF-005 (anchor existence), CTX-002 (term consistency) |
| Document proliferation | STR-001 (required files), SEC-001 (required sections), REF-004 (cross-zone deps), GRP-001–003 (graph health) |
Just declare rules and target files in a configuration file (`contextlint.config.json`).
```jsonc
{
"$schema": "https://raw.githubusercontent.com/nozomi-koborinai/contextlint/main/schema.json",
// Default validation targets (all Markdown under docs)
"include": ["docs/**/*.md"],
"rules": [
// Prevent empty cells in ID and Stability columns
{ "rule": "tbl002", "options": { "columns": ["ID", "Stability"] } },
// Restrict Stability values to draft / review / stable
{
"rule": "tbl003",
"options": {
"column": "Stability",
"values": ["draft", "review", "stable"]
}
},
// Verify relative links between documents exist
{ "rule": "ref001" }
]
}
```
## Rule System
Let's look at each category's rules in detail.
### Table Validation (TBL)
Rules for validating Markdown table contents.
| Rule | Overview |
| ------- | ---------------------------------------------------------- |
| TBL-001 | Required columns exist in a table |
| TBL-002 | No empty cells in specified columns |
| TBL-003 | Column values are within an allowed list |
| TBL-004 | Column values match a regex pattern |
| TBL-005 | Conditional constraints (constraints on one column based on another) |
| TBL-006 | Column values are unique across the entire project |
For the stability management example above, the following configuration achieves "prevent empty stability cells" and "accept only allowed values":
```json
{
"rules": [
{
"rule": "tbl002",
"options": { "columns": ["ID", "Stability"] }
},
{
"rule": "tbl003",
"options": { "column": "Stability", "values": ["draft", "review", "stable"] }
}
]
}
```
### Structure Validation (SEC / STR)
Rules for validating document structure.
| Rule | Overview |
| ------- | ----------------------------------------------------- |
| SEC-001 | Required sections (headings) exist in specified files |
| SEC-002 | Section headings appear in a specified order |
| STR-001 | Required files exist in the project |
For example, you can enforce that every `requirements.md` has `Business Value (Why)` and `Requirements (What)` sections.
```json
{
"rule": "sec001",
"options": {
"sections": ["Business Value (Why)", "Requirements (What)"],
"files": "**/requirements.md"
}
}
```
SEC-002 goes further by enforcing not just the "existence" but also the "order" of sections.
This is useful for template-driven documents (ADRs, RFCs, etc.) where you want to standardize an order like Overview → Requirements → Design.
### Checklist Validation (CHK)
Rules for validating Markdown checklists.
| Rule | Overview |
| ------- | ------------------------------------------------------------ |
| CHK-001 | All checklist items in a specified section are completed |
Useful for enforcing checklist completion in CI — for review checklists, release gates, and similar workflows.
```json
{
"rule": "chk001",
"options": {
"section": "Review Checklist",
"files": "docs/reviews/*.md"
}
}
```
### Reference Validation (REF)
Rules for validating cross-document reference integrity.
| Rule | Overview |
| ------- | --------------------------------------------------------------------- |
| REF-001 | Markdown link targets exist |
| REF-002 | Cross-file traceability of requirement IDs |
| REF-003 | Stability ordering constraints (stable items don't depend on draft) |
| REF-004 | Cross-zone dependencies are declared |
| REF-005 | Anchor fragments (`#heading`) exist in the target document's headings |
| REF-006 | Image reference targets (``) exist |
REF-001 is the rule that verifies whether "ubiquitous language inheritance links are not broken."
### Context Validation (CTX)
Rules for validating document content quality.
| Rule | Overview |
| ------- | ----------------------------------------------------------------------- |
| CTX-001 | Placeholder content (TODO, TBD, FIXME, etc.) has not been left behind |
| CTX-002 | Terms are consistent with the project glossary |
CTX-001 detects placeholder content like `TODO` or `TBD` left in documents. While useful during drafting, leftover placeholders risk AI generating code based on incomplete specifications.
```json
{
"rule": "ctx001",
"options": {
"patterns": ["TODO", "TBD", "FIXME", "PLACEHOLDER"]
}
}
```
CTX-002 verifies that terms defined in the Layer 1 glossary are used consistently across documents. This goes beyond checking that inheritance links exist (REF-001) — it validates **term-level consistency**.
```json
{
"rule": "ctx002",
"options": {
"glossaryFile": "docs/foundation/glossary.md",
"termColumn": "Term",
"files": "docs/zones/**/*.md"
}
}
```
### Graph Validation (GRP)
Rules that analyze inter-document dependencies as a graph and detect structural issues. contextlint's **Context Graph Engine** builds the dependency graph and validates it with the following rules.
| Rule | Overview |
| ------- | ----------------------------------------------------------------- |
| GRP-001 | Traceability chain from requirements to code is not broken |
| GRP-002 | No circular references in document dependencies |
| GRP-003 | No orphan documents that are not referenced from anywhere |
The "requirements → specifications → design → code" traceability described earlier — GRP-001 verifies that this **chain is not broken** using graph analysis. GRP-002 detects circular dependencies like A → B → C → A, and GRP-003 finds "orphan" documents that are not referenced from anywhere.
In projects with dozens of files or more, finding such structural issues by eye is virtually impossible. Graph validation fills that gap.
### Difference from markdownlint
Unlike markdownlint, which validates Markdown **syntax and formatting**, contextlint specializes in **semantic content consistency and cross-file integrity**.
| Aspect | markdownlint | contextlint |
| -------- | -------------------------------------- | ------------------------------------------------ |
| Target | Syntax and formatting | Content structure and semantics |
| Examples | Heading level order, list indentation | Required table columns, ID uniqueness, link targets |
| Role | How Markdown is written | What Markdown contains |
The two are complementary — using both together is recommended.
### Package Structure
contextlint is a monorepo composed of 3 packages.
| Package | Role |
| ------------------------- | --------------------------------------------------- |
| `@contextlint/core` | Rule engine and Markdown parser |
| `@contextlint/cli` | CLI entry point (the `contextlint` command) |
| `@contextlint/mcp-server` | MCP server for AI tool integration |
## Usage
### Quick Start
```bash
# Install
npm install -D @contextlint/cli
# Generate config interactively (English, Japanese, Chinese, Korean)
npx contextlint init
# Run (auto-detects contextlint.config.json from current directory)
npx contextlint
```
### Configuration File Example
Here's a configuration file from a real project:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/nozomi-koborinai/contextlint/main/schema.json",
// Validation target file patterns
"include": ["docs/**/*.md"],
"rules": [
// --- Table Validation ---
// Prevent empty cells in ID and Stability columns
{
"rule": "tbl002",
"options": { "columns": ["ID", "Stability"] }
},
// Restrict Stability values to draft / review / stable
{
"rule": "tbl003",
"options": {
"column": "Stability",
"values": ["draft", "review", "stable"]
}
},
// Require IDs to follow a naming convention like REQ-AUTH-01
{
"rule": "tbl004",
"options": {
"column": "ID",
"pattern": "^REQ-[A-Z]+-\\d{2,3}$"
}
},
// Ensure requirement IDs are unique across the project
{
"rule": "tbl006",
"options": {
"files": "**/requirements.md",
"column": "ID"
}
},
// --- Structure Validation ---
// Ensure required sections exist in requirements.md
{
"rule": "sec001",
"options": {
"sections": ["Business Value (Why)", "Requirements (What)"],
"files": "**/requirements.md"
}
},
// Ensure required files exist in the project
{
"rule": "str001",
"options": {
"files": [
"docs/zones/auth/requirements.md",
"docs/zones/todo/requirements.md"
]
}
},
// --- Reference Validation ---
// Verify relative links between documents exist
{
"rule": "ref001"
},
// Verify requirement IDs are correctly referenced from spec files
{
"rule": "ref002",
"options": {
"definitions": "**/requirements.md",
"references": ["**/spec_*.md", "**/overview.md"],
"idColumn": "ID",
"idPattern": "^REQ-"
}
},
// Ensure stable specs don't depend on draft requirements
{
"rule": "ref003",
"options": {
"stabilityColumn": "Stability",
"stabilityOrder": ["draft", "review", "stable"],
"definitions": "**/requirements.md",
"references": ["**/spec_*.md"]
}
},
// Ensure cross-zone links are declared in overview files
{
"rule": "ref004",
"options": {
"zonesDir": "docs/zones",
"dependencySection": "External Zone Dependencies"
}
}
]
}
```
With this configuration, the following are automatically verified:
- `ID` and `Stability` columns in requirement tables are not empty (TBL-002)
- Stability values are one of `draft` / `review` / `stable` (TBL-003)
- Requirement IDs follow a naming convention like `REQ-AUTH-01` (TBL-004)
- Requirement IDs are unique across the project (TBL-006)
- Required sections exist in `requirements.md` (SEC-001)
- Required files exist (STR-001)
- Relative links between documents point to existing targets (REF-001)
- Requirement IDs are referenced from specification files (REF-002)
- Stable specifications don't depend on draft requirements (REF-003)
- Cross-zone dependencies are declared (REF-004)
### Execution Output
Running `contextlint` reports violations per file:
```text
$ npx contextlint
docs/zones/todo/requirements.md
line 5 error Column "Stability" has invalid value "wip" (allowed: draft, review, stable) TBL-003
line 5 error Column "ID" value "REQ_TODO_03" does not match pattern ^REQ-[A-Z]+-\d{2,3}$ TBL-004
docs/zones/auth/table_users.md
line 8 warning Empty cell in column "Stability" TBL-002
line 12 error Link target "../../foundation/glossary.md" does not exist REF-001
2 errors, 1 warning in 2 files
```
### CLI Subcommands
Starting from v0.6.0, contextlint provides subcommands for analyzing and leveraging document dependencies beyond linting.
| Command | Overview |
| --------------------------- | ----------------------------------------------------------- |
| `contextlint` | Run lint based on the config file (default) |
| `contextlint init` | Generate a config file interactively |
| `contextlint impact ` | Show the blast radius of changes to a given file |
| `contextlint slice ` | Extract a subset of documents related to a given query |
| `contextlint graph` | Visualize the dependency graph between documents |
| `contextlint compile` | Generate SKILL.md from documents and config (Context Compiler) |
The `--watch` flag enables real-time linting on file changes, and `--format json` outputs results in JSON.
```bash
# Watch for file changes and lint in real time
npx contextlint --watch
# Output in JSON format (useful for CI and scripting)
npx contextlint --format json
# Analyze the impact of changing a specific file
npx contextlint impact docs/zones/auth/requirements.md
# Visualize the dependency graph
npx contextlint graph
```
## CI Integration
Starting from v0.6, contextlint ships an official Composite Action that runs the linter with `--format json` and surfaces violations as line-level annotations on the PR diff.
```yaml
name: contextlint
on:
pull_request:
paths: ["docs/**"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nozomi-koborinai/contextlint/.github/actions/contextlint@main
# with:
# config: 'contextlint.config.json' # optional
# files: 'docs/**/*.md' # optional
# version: 'latest' # optional
```
Validation runs automatically on PRs that modify documents, catching broken structures **before merge**.
If you'd rather not pin to a third-party action, plain `npx @contextlint/cli` works too — pair it with `--format json` if you want to feed the output into your own reporter.
## AI Tool Integration
contextlint also works as an MCP (Model Context Protocol) server.
```json
{
"mcpServers": {
"contextlint": {
"command": "npx",
"args": ["@contextlint/mcp-server"]
}
}
}
```
The MCP server provides six tools:
| Tool | Overview |
| ----------------- | ------------------------------------------------------------------- |
| `lint` | Pass Markdown content directly with rules for inline linting |
| `lint-files` | Specify a config file and glob patterns for file-based linting |
| `context-graph` | Retrieve the dependency graph between documents |
| `context-slice` | Extract a subset of documents related to a given query |
| `impact-analysis` | Analyze the blast radius of changes to a given file |
| `compile-context` | Generate SKILL.md from documents and config (Context Compiler) |
With this setup, AI tools like Claude and Cursor can run document linting and dependency analysis within conversations. For example:
1. Have AI edit a document
2. After editing, run `lint-files` via MCP to check consistency
3. If violations are found, AI automatically fixes them and re-runs lint
4. Use `impact-analysis` to check the blast radius and update related documents
*AI checking document consistency via MCP, identifying violations and suggesting fixes*
You can also use Claude Code Hooks or Cursor Hooks to run the CLI directly without MCP.
Set up a hook to auto-run `npx contextlint` after document edits, and you get the same workflow without the MCP server.
Either way, CI acts as a guardrail to "not let broken things through," while AI tool integration is a mechanism to "not create broken things in the first place."
### Context Compiler
The Context Compiler, added in v0.7.0, **deterministically** generates custom skills (`SKILL.md`) for Claude Code from your project's documents and contextlint configuration. No LLM is used — same input always yields the same output.
```bash
npx contextlint compile
```
#### Configuration
Add a `compile` section to your `contextlint.config.json`:
```json
{
"include": ["docs/**/*.md"],
"compile": {
"skill": {
"name": "my-project-docs",
"description": "Validate and maintain project documentation"
},
"outdir": ".claude/skills/my-project"
},
"rules": [...]
}
```
#### Generated SKILL.md Structure
The `compile` command analyzes the dependency graph and outputs a SKILL.md under `.claude/skills/` with four sections:
| Section | Content |
| --------------------- | ------------------------------------------------------------------------------ |
| Document Architecture | File tree with graph roles (entry / hub / leaf / bridge / isolated) |
| Document Rules | Applied rules described in natural language, grouped by category |
| Document Dependencies | Dynamic context injection via `impact` / `slice` commands |
| Workflow | Guidance for creating and editing documents |
Here's what a generated SKILL.md looks like:
```markdown
---
name: my-project-docs
description: "Validate and maintain project documentation"
---
## Document Architecture
### File Tree
| Path | Role |
|------|------|
| `docs/zones/auth/overview.md` | entry point |
| `docs/zones/auth/requirements.md` | hub |
| `docs/zones/auth/table_users.md` | leaf |
### Document Types
- **[ID, Requirement, Stability]** - 2 table(s) (ID format: `REQ-NNN`)
## Document Rules
### Table Structure
- **TBL-002**: Columns "ID", "Stability" must not be empty
- **TBL-003**: Column "Stability" values must be one of: draft, review, stable
### References
- **REF-001**: All relative links must point to existing files
## Document Dependencies
### Impact Analysis (dynamic)
!`npx contextlint impact $ARGUMENTS`
### Related Documents (dynamic)
!`npx contextlint slice $ARGUMENTS`
```
The key part is the `` !`npx contextlint impact $ARGUMENTS` `` syntax in the `Document Dependencies` section. This leverages Claude Code's [**Dynamic Context Injection**](https://code.claude.com/docs/en/skills#inject-dynamic-context) — when the skill is loaded, the commands execute automatically and their output is embedded into the prompt.
This means when Claude Code starts working on document-related tasks, the following happens automatically:
1. Document structure and rules are understood from SKILL.md
2. `contextlint impact` / `slice` results reveal the blast radius of changes
3. Code is generated and modified based on that context
If MCP is a real-time "conversational" integration, the Context Compiler is a "pre-load context" approach.
### Direct Execution from Claude Code
In Claude Code, you can run shell commands directly within a conversation using the `!` prefix:
```
! npx contextlint
! npx contextlint impact docs/zones/auth/requirements.md
! npx contextlint graph
```
The output is added to the conversation context, so Claude can propose next actions based on the results. Even without setting up an MCP server, you can integrate contextlint into your AI workflow using just `!` commands.
## Use Cases Beyond SDD
This article used SDD as an example, but `contextlint`'s rules are general-purpose — they can be used in any project that manages documents in Markdown.
- **ADR (Architecture Decision Records)** — Use SEC-001 to enforce required sections (Status, Context, Decision) and TBL-003 to constrain status values
- **Docs as Code in general** — Auto-detect broken references, duplicate IDs, and missing files in CI
Managing documents in Git like code and setting up CI guardrails.
Adding `contextlint` to such Docs as Code practices, alongside markdownlint, broadens your coverage.
## Conclusion
Code has ESLint and type checking, but there's no equivalent for verifying document contents.
`contextlint` is a tool I started building to fill that gap.
- Validates required table columns and value constraints
- Validates cross-document reference integrity
- Validates requirement ID uniqueness and traceability
- Detects term inconsistencies and leftover placeholders
- Analyzes dependency graphs to catch circular references and orphan documents
- Visualizes and leverages structure via `impact` / `slice` / `graph` / `compile`
- Runs automatically in CI, integrates with AI tools via MCP
- **Validates AI-oriented documents, but does not depend on AI**
If you manage documents in Markdown — whether for SDD, ADR, or anything else — give it a try.
---
## contextlint v0.9 — Editor Integration with LSP Server and VS Code Extension
URL: https://koborin.ai/tech/contextlint-lsp-and-editor-integration/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import diagnosticImage from '../../../assets/tech/contextlint-lsp-and-editor-integration/diagnostic-with-hover.png';
import overviewImage from '../../../assets/tech/contextlint-lsp-and-editor-integration/diagnostics-overview.png';
import quickfixPopupImage from '../../../assets/tech/contextlint-lsp-and-editor-integration/quickfix-tbl002-popup.png';
import quickfixAppliedImage from '../../../assets/tech/contextlint-lsp-and-editor-integration/quickfix-applied.png';
import contextlintLspOg from '../../../assets/og/contextlint-lsp.png';
## Introduction
A while back, I introduced **contextlint** — a rule-based linter that verifies consistency across Markdown documents.
That article covered the rule system, the CLI, MCP integration, and the Context Compiler — most of which landed by **v0.7**.
In **v0.9**, the project crossed a different kind of line: contextlint now ships a **Language Server Protocol (LSP)** implementation. Open a Markdown file in VS Code, Cursor, Neovim, Helix, or any LSP-capable editor, and you get real-time diagnostics, hover info, and Quick Fixes — the same rules CI runs, but on every keystroke.
This is a follow-up focused on that v0.9 step.
## Three layers of feedback
Before v0.9, contextlint covered two layers of feedback:
| Layer | Where | When you find out |
| ------ | --------------------------- | -------------------------- |
| **CI** | GitHub Actions | At PR review time |
| **MCP**| Claude / Cursor over MCP | After the AI finishes editing |
Both work, but neither catches the broken table the moment a human types it.
v0.9 adds the missing layer:
| Layer | Where | When you find out |
| ------- | --------------------------- | ----------------------------- |
| **LSP** | Your editor | While you're typing |
| **MCP** | Claude / Cursor over MCP | After the AI finishes editing |
| **CI** | GitHub Actions | At PR review time |
CI keeps broken docs out of `main`. MCP keeps AI-generated docs from being broken in the first place. LSP keeps the **human editing loop** honest. The three layers complement each other rather than replace each other.
## `@contextlint/lsp-server`
The new package is `@contextlint/lsp-server`, exposing a `contextlint-lsp` binary built on `vscode-languageserver`.
```bash
npm install -D @contextlint/lsp-server
```
A few things worth noting about the server itself:
- **Same config as CLI / MCP.** The server walks up from the workspace root looking for `contextlint.config.json` — exactly like `npx contextlint` and the MCP server. One config file drives all three.
- **Workspace-wide diagnostics.** Project-scope rules — REF-001, REF-002, TBL-006, GRP-001 / 002 / 003, CTX-002 — work across the whole workspace, not just the open file. An in-memory document cache keeps that fast.
- **Diagnostics land at the actual line.** Document-scope rules (TBL-002, CHK-001, …) report at the offending row. Project-scope rules report at the actual file and line of the violation, not the rule's defining file. This was a deliberate v0.9 fix so diagnostics map cleanly to editor squiggles.
In other words, the LSP isn't a stripped-down subset of the CLI — it runs the same rule engine on the same graph.
*TBL-001 evaluates the table as a whole, so the squiggle lands on the header row rather than a specific cell. Hovering reveals the rule ID and the missing column names — `ID` and `Status` in this example.*
> All screenshots in this article are taken against one example `contextlint.config.json`. Which rules fire and where the squiggles appear depends on each project's config.
## VS Code / Cursor
The simplest way in is the bundled VS Code extension, distributed as a VSIX on every release.
1. Grab `contextlint-vscode-VERSION.vsix` from the [latest GitHub Release](https://github.com/nozomi-koborinai/contextlint/releases).
2. From the Extensions view: **Views and More Actions… → Install from VSIX…** and pick the file.
3. Or from the command line:
```bash
code --install-extension contextlint-vscode-VERSION.vsix
```
The extension activates on Markdown files, finds the nearest `contextlint.config.json`, and starts `@contextlint/lsp-server` automatically. No further setup needed.
The extension is currently VSIX-only — Marketplace publication is tracked separately and planned for a future release. Cursor uses the same extension format, so the same VSIX works there.
*TBL-001, TBL-002, and CHK-001 violations diagnosed simultaneously across one file. The diagnostic count appears next to the file name in the explorer.*
## Neovim, Helix, JetBrains
For everything else, the `contextlint-lsp` binary should be enough on any LSP-capable editor. The snippets below cover the most common setups. They're expected to work, but I haven't personally verified them outside VS Code / Cursor yet — if you hit a snag, an issue on the repo is appreciated.
### Neovim ([nvim-lspconfig](https://github.com/neovim/nvim-lspconfig))
```lua
local configs = require('lspconfig.configs')
local util = require('lspconfig.util')
if not configs.contextlint then
configs.contextlint = {
default_config = {
cmd = { 'npx', 'contextlint-lsp' },
filetypes = { 'markdown' },
root_dir = util.root_pattern('contextlint.config.json', '.git'),
single_file_support = false,
},
}
end
require('lspconfig').contextlint.setup({})
```
### Helix (`~/.config/helix/languages.toml`)
```toml
[language-server.contextlint]
command = "npx"
args = ["contextlint-lsp"]
[[language]]
name = "markdown"
language-servers = ["contextlint"]
```
### JetBrains IDEs (via [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij))
1. Install the **LSP4IJ** plugin from the Marketplace.
2. Open **Settings → Languages & Frameworks → Language Servers → Add**:
- **Name**: `contextlint`
- **Command**: `npx contextlint-lsp`
- **Mapping → File name patterns**: `*.md`
After external changes (for example `git pull`), reload the editor window so the workspace cache picks them up.
## Quick Fixes
Diagnostics tell you something is wrong. Quick Fixes — Code Actions in LSP terms — actually fix it. v0.9 ships two:
| Rule | Quick Fix |
| ----------- | -------------------------------------------------- |
| **CHK-001** | Check the unchecked checklist item |
| **TBL-002** | Insert a `TODO` placeholder into the empty cell |
The intent isn't to auto-fix everything. It's to remove the "first physical step" — the keystrokes you'd be repeating by hand — for the violations that show up most often during editing.
Both fixes are mechanical and deterministic. A `TODO` placeholder isn't a real value, but it gets the document into a syntactically valid state so the rest of the rules can keep running, and it leaves a marker for you to come back to. CTX-001 (placeholder content) will then flag that `TODO` later, which is the point: the rule chain catches it twice — once when you typed nothing, once when you forgot to come back.
*The Quick Fix for TBL-002 — a single Code Action fills the empty cell with a `TODO` placeholder.*
*After applying the Quick Fixes — the empty cell now holds `TODO`, and the checklist items are flipped to `[x]`. The squiggle on the first table remains because TBL-001 ("missing required column") doesn't ship a Quick Fix yet.*
More Code Actions are likely to follow as common patterns surface.
## Programmatic API (appendix)
The graph engine the LSP server relies on is also exposed as a public API on `@contextlint/core`. If you're building tools around your docs, you can bypass the CLI and use it directly.
```typescript
import {
parseDocument,
buildContextGraph,
getImpactSet,
getContextSlice,
topologicalSort,
classifyImpact,
} from "@contextlint/core";
```
| Function | Description |
| ----------------------------------------------------- | -------------------------------------------------------------------- |
| `buildContextGraph(documents)` | Build a dependency graph from parsed documents |
| `getImpactSet(graph, filePath)` | Files affected by changing a given file (direct + transitive) |
| `getContextSlice(graph, documents, query, maxDepth?)` | Minimal set of files relevant to a query |
| `topologicalSort(graph)` | Topological sort of the document graph (dependency order) |
| `classifyImpact(graph, filePath)` | Classify each affected file as direct vs transitive |
This is the same machinery behind `contextlint impact`, `contextlint slice`, and the LSP's project-scope diagnostics. If your editor integration or docs pipeline needs custom analysis, you don't need to fork the CLI.
## Wrapping up
What v0.9 changed isn't a single feature so much as **where contextlint runs**:
- **LSP** — while you're editing
- **MCP** — while AI is editing
- **CI** — before merge
The same rules. The same config. The same graph. Just earlier feedback.
Catching things at CI still matters, but bringing that same feedback into the editor — where you actually write — was the part of v0.9 that felt right to me. Building things that nudge developer experience forward, even a little, is the kind of work I enjoy.
---
## Understanding Authentication Tokens with Firebase Auth
URL: https://koborin.ai/tech/firebase-auth-token-deep-dive/
import SiteImage from '../../../components/SiteImage.astro';
import firebaseAuthTokenDeepDiveOg from '../../../assets/og/firebase-auth-token-deep-dive.png';
> **Note:** This article is a personal study note from when I was researching Firebase Auth tokens. It's more of a compilation of what I learned during the process rather than a comprehensive guide.
This guide covers the fundamentals of authentication tokens in OAuth 2.0 / OpenID Connect, with a focus on Firebase Auth's unique characteristics.
---
## 1. Authentication vs Authorization
| Concept | Description | Examples |
|---------|-------------|----------|
| **Authentication** | Verifying "who you are" | Login process, password verification, SAML authentication |
| **Authorization** | Verifying "what you can do" | Admin permission check, group membership verification |
- Authentication comes first, then authorization. An unauthenticated user cannot be authorized.
---
## 2. Token Types and Roles
### 2.1 Tokens in OAuth 2.0 / OpenID Connect
| Token | Purpose | Content | Usage Location | Typical Validity |
|-------|---------|---------|----------------|------------------|
| **ID Token** | User identification | UID, email, name, etc. | Frontend | 5 min - 1 hour |
| **Access Token** | Resource access authorization | Scopes, permissions | Backend API | 5 min - 1 hour |
| **Refresh Token** | Token renewal | Random string | Token refresh requests | 7 - 90 days |
### 2.2 ID Token
- **Purpose**: Prove "who this person is"
- **Usage**: Consumed within the client (frontend)
- **Use cases**: Display username, show profile, personalize UI
- **Format**: JWT (JSON Web Token)
### 2.3 Access Token
- **Purpose**: Prove "authorization to access this resource"
- **Usage**: Sent to backend API (`Authorization: Bearer {token}`)
- **Use cases**: API authorization, resource access control
- **Format**: JWT or Opaque Token
### 2.4 Refresh Token
- **Purpose**: Obtain new ID Token / Access Token
- **Usage**: Token refresh requests to the auth server
- **Use cases**: Get new tokens when Access Token expires, without re-login
- **Format**: Opaque Token (contents not visible)
---
## 3. JWT (JSON Web Token) Structure
ID Tokens and Access Tokens are often created in JWT format. A JWT consists of three parts:
```
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL...
├──────────── Header ────────────┤├────────── Payload ──────────...
```
### 3.1 Header
```json
{
"alg": "RS256",
"typ": "JWT"
}
```
- `alg`: Signature algorithm
- `typ`: Token type
### 3.2 Payload = Collection of Claims
```json
{
"iss": "https://securetoken.google.com/my-project",
"sub": "XeOK4KBxyz123abc",
"aud": "my-project",
"iat": 1704067200,
"exp": 1704070800,
"email": "user@example.com",
"firebase": {
"sign_in_provider": "password"
}
}
```
| Claim | Meaning |
|-------|---------|
| `iss` | Issuer |
| `sub` | Subject = User ID (UID) |
| `aud` | Audience = Project ID |
| `iat` | Issued At (timestamp) |
| `exp` | Expiration (timestamp) |
| `email` | Email address |
### 3.3 Signature
- Header and Payload are signed with a private key
- If tampered with, the signature won't match
- Server-side verification via `verifyIdToken()` etc.
---
## 4. OAuth 2.0 / OpenID Connect Flow
### 4.1 Token Acquisition Timing
Upon successful authentication, **ID Token / Access Token / Refresh Token are issued simultaneously** in a single response:
```json
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyB...",
"id_token": "eyJhbGci..."
}
```
### 4.2 Validity Period Management
- **Auth server decides**: The auth server (IdP) sets all token validity periods
- **For JWTs**: The `exp` claim contains the expiration timestamp
- **Configurability**: Varies by provider - some allow developer configuration, others don't
### 4.3 Token Refresh Flow
```mermaid
sequenceDiagram
autonumber
participant C as Client
participant AS as Auth Server
Note over C,AS: Normal Refresh Flow
C->>C: Detect Access Token expiration (check exp claim)
C->>AS: POST /oauth/token grant_type=refresh_token refresh_token={Refresh Token}
AS->>AS: Validate Refresh Token - Check expiration - Verify not revoked
alt Refresh Token Valid
AS-->>C: 200 OK new access_token new id_token (new refresh_token)
C->>C: Store new tokens
Note over C: API calls can continue User doesn't need to re-login
else Refresh Token Expired/Invalid
AS-->>C: 401 Unauthorized invalid_grant
C->>C: Clear stored tokens
Note over C: Prompt user to re-login
end
Note over C,AS: Refresh Token Rotation (Enhanced Security)
rect rgb(240, 248, 255)
C->>AS: Request refresh with RT-1
AS->>AS: Invalidate RT-1
AS-->>C: new access_token new id_token new refresh_token=RT-2
Note over C,AS: RT-1 can never be used again Prevents attacks with leaked old tokens
end
```
- When Access Token expires, use Refresh Token to get new tokens
- **A single Refresh Token refreshes both ID Token and Access Token**
- User doesn't need to re-login
---
## 5. Firebase Auth Characteristics
### 5.1 ID Token Serves as Access Token
| Aspect | Standard OAuth 2.0 | Firebase Auth |
|--------|-------------------|---------------|
| Token types | ID Token + Access Token | **ID Token only** |
| API authentication | Uses Access Token | **Uses ID Token** |
| Management complexity | Manage 2 types | Manage 1 type |
- In Firebase, ID Token handles both "authentication" and "authorization"
- Simpler for developers - only one token type to manage
### 5.2 Token Validity Periods
| Item | Firebase Auth |
|------|---------------|
| ID Token validity | **1 hour (fixed, not configurable)** |
| Refresh Token validity | **Virtually unlimited** |
Firebase Refresh Token is invalidated only when:
- User is deleted
- User is disabled
- Password or email address is changed
- `revokeRefreshTokens(uid)` is explicitly called
### 5.3 SDK Types and Usage
#### Client-side SDK (Automatic Refresh Token Usage)
| SDK | Use Case |
|-----|----------|
| Firebase JavaScript SDK (`firebase/auth`) | Web apps |
| Firebase iOS SDK | iOS apps |
| Firebase Android SDK | Android apps |
| Firebase Flutter SDK | Cross-platform apps |
- Just call `user.getIdToken()` - automatically refreshes when expired
- Developers don't need to handle Refresh Token directly
#### Server-side Admin SDK (Refresh Token Management/Revocation)
| SDK | Use Case |
|-----|----------|
| Firebase Admin SDK for Node.js | Server-side |
| Firebase Admin SDK for Python | Server-side |
| Firebase Admin SDK for Java | Server-side |
| Firebase Admin SDK for Go | Server-side |
- Cannot directly access Refresh Token
- Can only invalidate via `revokeRefreshTokens(uid)`
### 5.4 User Experience and Security Balance
| Perspective | What It Looks Like |
|-------------|-------------------|
| **User's view** | "I'm always logged in" |
| **Internal behavior** | ID Token expires every hour and gets renewed as needed |
**Why this design?**
| Aspect | Reason |
|--------|--------|
| **Short ID Token (1 hour)** | Even if leaked, becomes invalid after 1 hour (limits damage) |
| **Long Refresh Token (virtually unlimited)** | Users don't want to log in repeatedly (UX priority) |
| **SDK auto-refresh** | Security and UX balance without developer effort |
Firebase Auth achieves "**security-wise tokens change every hour, but UX-wise the user stays logged in**".
---
## 6. Browser Storage Types
Where to store tokens and session information involves security vs. convenience trade-offs.
### 6.1 Storage Types Overview
| Storage | Lifetime | Cross-tab | Auto-sent to Server | JS Access | Capacity |
|---------|----------|-----------|---------------------|-----------|----------|
| **Session Storage** | Until tab closes | No | No | Yes | ~5MB |
| **Local Storage** | Persistent (until deleted) | Yes | No | Yes | ~5MB |
| **Cookie** | Set via maxAge/expires | Yes | **Yes** | Blocked by httpOnly | ~4KB |
| **IndexedDB** | Persistent | Yes | No | Yes | Large |
### 6.2 Session Storage
- **Storage location**: Browser (Web Storage API)
- **Lifetime**: Until tab/window closes
- **Scope**: Same tab only (not shared across tabs)
- **Use cases**: Temporary data (UI state needed only while logged in)
```javascript
// Store
sessionStorage.setItem('key', 'value');
// Retrieve
const value = sessionStorage.getItem('key');
// Delete
sessionStorage.removeItem('key');
```
### 6.3 Local Storage
- **Storage location**: Browser (Web Storage API)
- **Lifetime**: Persistent until explicitly deleted
- **Scope**: Shared within same origin (across all tabs)
- **Use cases**: User settings, cache data
```javascript
// Store
localStorage.setItem('key', 'value');
// Retrieve
const value = localStorage.getItem('key');
// Delete
localStorage.removeItem('key');
```
### 6.4 Cookie
- **Storage location**: Browser
- **Lifetime**: Set via maxAge/expires
- **Scope**: Same origin (subdomains configurable)
- **Key feature**: **Automatically sent to server with requests**
- **Use cases**: Auth tokens, session management
```javascript
// Set (server-side)
response.cookies.set('name', 'value', {
httpOnly: true, // JS cannot access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 86400, // 1 day (seconds)
});
```
### 6.5 IndexedDB
- **Storage location**: Browser (NoSQL database)
- **Lifetime**: Persistent until explicitly deleted
- **Scope**: Within same origin
- **Use cases**: Large data, offline data, **Firebase SDK token storage**
Firebase Auth SDK automatically stores ID Token and Refresh Token in IndexedDB:
```
IndexedDB: firebaseLocalStorageDb
└─ firebaseLocalStorage
└─ firebase:authUser:{apiKey}:{appName}
```
### 6.6 Token Storage Location Selection
| Storage Location | XSS Attack Risk | Recommended Use |
|------------------|-----------------|-----------------|
| Session Storage | Can be stolen | Temporary data |
| Local Storage | Can be stolen | Non-sensitive data |
| **Cookie (httpOnly)** | **Cannot be read by JS** | **Auth tokens (recommended)** |
| IndexedDB | Can be stolen | Firebase SDK default |
| Memory only | Lost on page navigation | Most secure but inconvenient |
### 6.7 Session Storage vs Session Cookie
These have similar names but completely different mechanisms:
| Aspect | Session Storage | Session Cookie |
|--------|-----------------|----------------|
| Manager | Browser | Server issues → Browser stores |
| Lifetime | Until tab closes | Server-defined (5 min - 14 days) |
| Server transmission | Manual | Automatic |
| httpOnly | Not available (always JS accessible) | Configurable |
| Use case | Temporary UI state | Auth session management |
---
## 7. Session Cookie (Firebase Auth Context)
### 7.1 Cookie Types (General Definition)
Cookies are classified into 2 types based on expiration settings:
| Type | Expiration Setting | Disappears When | Use Case |
|------|-------------------|-----------------|----------|
| **Session Cookie** | None (`maxAge`/`expires` not set) | Browser closes | Temporary session info |
| **Persistent Cookie** | Set (`maxAge`/`expires` defined) | Expiration reached | Maintain login state |
### 7.2 Firebase Auth's "Session Cookie"
In Firebase Auth documentation, "Session Cookie" has a **different meaning** from the general definition:
| Aspect | General Session Cookie | Firebase Auth Session Cookie |
|--------|------------------------|------------------------------|
| Definition | Cookie without `maxAge`/`expires` | Cookie issued by `createSessionCookie()` |
| Lifetime | Until browser closes | **5 min - 14 days (configurable)** |
| Technical classification | Session Cookie | **Persistent Cookie** |
| Use case | Temporary session | Server-side session management |
**In other words, Firebase's "Session Cookie" is technically a Persistent Cookie.**
### 7.3 Session Cookie Overview (Firebase Context)
Firebase Auth Session Cookie is a mechanism for managing user sessions server-side. Send ID Token to server, convert it to Session Cookie, then use Cookie-based auth for subsequent requests.
| Aspect | ID Token Direct Usage | Session Cookie Usage |
|--------|----------------------|---------------------|
| Storage | Client (memory/IndexedDB) | Server issues → Browser Cookie |
| Validity | 1 hour (fixed) | 5 min - 14 days (configurable) |
| Refresh | Client SDK automatic | Server-side explicit renewal |
| Security | JavaScript accessible | httpOnly blocks JavaScript access |
### 7.4 Creating Session Cookie (Firebase Admin SDK)
```typescript
// Server-side: Receive ID Token and issue Session Cookie
const expiresIn = 60 * 60 * 24 * 1000; // 1 day (milliseconds)
// Verify ID Token and create Session Cookie
const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn });
// Set Cookie in response
response.cookies.set('__session', sessionCookie, {
httpOnly: true, // JavaScript cannot access (XSS protection)
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: expiresIn / 1000,
path: '/',
});
```
### 7.5 Verifying Session Cookie
```typescript
// Server-side: Verify Session Cookie
const sessionCookie = cookies().get('__session')?.value;
const decodedClaims = await adminAuth.verifySessionCookie(sessionCookie, true);
// Access decodedClaims.uid, decodedClaims.email, etc.
```
### 7.6 Security Flags
| Flag | Value | Effect |
|------|-------|--------|
| `httpOnly` | `true` | JavaScript cannot access (XSS protection) |
| `secure` | `true` | Cookie sent only over HTTPS |
| `sameSite` | `lax` | Only send Cookie for same-site requests (CSRF protection) |
| `maxAge` | seconds | Cookie validity period |
| `path` | `/` | Path where Cookie is valid |
### 7.7 Session Cookie Validity
| Item | Value |
|------|-------|
| Minimum | 5 minutes (300,000 milliseconds) |
| Maximum | 14 days (1,209,600,000 milliseconds) |
| Configuration | `expiresIn` option in `createSessionCookie()` |
### 7.8 ID Token Direct Usage vs Session Cookie
| Aspect | ID Token Direct Usage | Session Cookie |
|--------|----------------------|----------------|
| **Implementation complexity** | Simple | More complex (Cookie management needed) |
| **Validity flexibility** | 1 hour fixed | 5 min - 14 days configurable |
| **XSS resistance** | Lower (JS accessible) | Higher (httpOnly) |
| **CSRF resistance** | Higher (explicit header required) | Protected by sameSite |
| **Refresh** | SDK automatic | Manual refresh process needed |
| **SSR support** | Additional implementation needed | Cookie auto-sent |
#### Detailed Security Comparison
**XSS Attack Resistance**
| Method | Storage Location | XSS Risk |
|--------|------------------|----------|
| Session Cookie | Cookie (httpOnly) | JS cannot read. However, Cookie is auto-sent, so attacker could potentially call APIs |
| ID Token Direct Usage | IndexedDB / Memory | Risk of theft via `localStorage.getItem()` or IndexedDB. Can be obtained via Firebase SDK's `getIdToken()` |
**CSRF Attack Resistance**
| Method | Transmission | CSRF Resistance |
|--------|--------------|-----------------|
| Session Cookie | Auto-sent (Cookie) | `sameSite: 'lax'` limits to same-site requests. However, may be sent for GET requests |
| ID Token Direct Usage | Explicit (`Authorization: Bearer`) | Not auto-sent in CSRF, so higher resistance |
**Token Leakage Impact**
| Method | Validity | Mitigation |
|--------|----------|------------|
| Session Cookie | 1 day (configurable) | Invalidated after 1 day. Can call `revokeRefreshTokens()` server-side for immediate invalidation |
| ID Token Direct Usage | 1 hour (fixed) | Invalidated after 1 hour. However, if Refresh Token leaks, long-term impact (Firebase is virtually unlimited) |
**Decision Points**
| Aspect | ID Token Direct Usage Suits When | Session Cookie Suits When |
|--------|----------------------------------|---------------------------|
| XSS risk tolerance | Internal portal with low XSS risk | Want to minimize XSS risk |
| CSRF risk tolerance | Want complete CSRF prevention | No sensitive operations on GET requests |
| Implementation/maintenance cost | Want simplicity | Prioritize security |
| Token leakage impact | 1-hour invalidation is acceptable | 1-day validity is acceptable |
**Reference Documentation**:
- [OWASP - Cross-Site Scripting (XSS)](https://owasp.org/www-community/attacks/xss/)
- [OWASP - Cross-Site Request Forgery (CSRF)](https://owasp.org/www-community/attacks/csrf)
### 7.9 ID Token Direct Usage Method
Instead of using Session Cookie, send ID Token directly to API. Leverages Firebase SDK's auto-refresh feature.
**Characteristics**:
| Item | Content |
|------|---------|
| Session Cookie | Not needed (not created) |
| Session refresh | Not needed (SDK auto-manages) |
| API calls | Explicitly send via `Authorization: Bearer {idToken}` |
| Server verification | Use `verifyIdToken()` |
**API Call Example**:
```typescript
// Client-side
const user = auth.currentUser;
const idToken = await user.getIdToken(); // Auto-refresh if near expiration
const response = await fetch('/api/data', {
headers: {
'Authorization': `Bearer ${idToken}`,
},
});
```
**Server-side Verification**:
```typescript
// API Route
const authHeader = request.headers.get('Authorization');
const idToken = authHeader?.split('Bearer ')[1];
const decodedClaims = await adminAuth.verifyIdToken(idToken);
// Access decodedClaims.uid, decodedClaims.email, etc.
```
**Changes When Migrating from Session Cookie**:
| Change Location | Content |
|-----------------|---------|
| `/api/auth/session/route.ts` | Delete or disable |
| `session-refresh.ts` | Delete (not needed) |
| Each API Route | `verifySessionCookie()` → `verifyIdToken()` |
| Client API calls | Add `Authorization: Bearer {idToken}` |
---
## 8. Provider Comparison
| Provider | ID Token | Access Token | Refresh Token | Configurable? |
|----------|----------|--------------|---------------|---------------|
| **Firebase** | 1 hour | N/A (ID Token serves both) | Virtually unlimited | No |
| **Google** | 1 hour | 1 hour | 6 months (if unused) | No |
| **Microsoft** | 1 hour | 1 hour | 90 days (default) | Yes |
| **Auth0** | 10 hours | 24 hours | 2 weeks | Yes |
| **AWS Cognito** | 1 hour | 1 hour | 1 day - 3650 days | Yes |
| **Okta** | Configurable | Configurable | Configurable | Yes |
---
## 9. Glossary
| Term | Description |
|------|-------------|
| Authentication | Identity verification |
| Authorization | Permission verification |
| ID Token | Identity certificate (JWT) |
| Access Token | Resource access rights |
| Refresh Token | For token renewal |
| Session Cookie | Cookie for server-side session management |
| UID | User ID |
| Claims | Attributes within token |
| JWT | JSON Web Token format |
| Signature | Tamper prevention |
| IdP | Identity Provider (auth server) |
| Custom Claims | Developer-added custom attributes |
| httpOnly | Cookie flag preventing JavaScript access |
| secure | Cookie flag for HTTPS-only transmission |
| sameSite | Cookie flag for CSRF protection |
| XSS | Cross-Site Scripting attack |
| CSRF | Cross-Site Request Forgery attack |
| Session Storage | Browser storage that clears on tab close |
| Local Storage | Persistently stored browser storage |
| IndexedDB | In-browser NoSQL database |
| Persistent Cookie | Cookie with set expiration (persists until expiry) |
---
## References
- [Firebase Auth - Manage User Sessions](https://firebase.google.com/docs/auth/admin/manage-sessions)
- [Firebase Auth - Manage Session Cookies](https://firebase.google.com/docs/auth/admin/manage-cookies)
- [Firebase Auth - Verify ID Tokens](https://firebase.google.com/docs/auth/admin/verify-id-tokens)
- [OAuth 2.0 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
---
## Genkit vs ADK: A Guide to Choosing the Right Google AI Framework
URL: https://koborin.ai/tech/genkit-vs-adk/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import genkitVsAdkOg from '../../../assets/og/genkit-vs-adk.jpeg';
Google offers two open-source frameworks for AI development: **Genkit** and **Agent Development Kit (ADK)**.
Both can leverage Google's large language models (Gemini), but they differ in their target use cases, architecture, and developer-facing features.
This article compares Genkit and ADK across use cases, platform integrations, model support, development style, and maturity to help you understand the differences and choose the right framework for your project.
**For the time-pressed**: The Genkit team's subreddit [`r/GenkitFramework`](https://www.reddit.com/r/GenkitFramework/comments/1t6ib62/genkit_vs_adk_why_google_provides_two_agentic/) has a clear breakdown of when to use which, written from inside Genkit:
> if you're looking for the rich enterprise features of Agent Platform, and want to build multi-agent standalone systems, ADK is for you.
>
> if you're looking to add Gen AI or agentic features into your app, or want to build agents with lower level components especially using a variety of platforms and models, Genkit is the way to go.
That's the gist. The rest of this article fills in background, history, latest moves, and code examples.
---
## TL;DR
| Aspect | Genkit | ADK |
| --- | --- | --- |
| Primary Use Case | Adding GenAI features to apps (chat, summarization, RAG, etc.) | Building multi-agent systems with collaborative agents |
| Languages | Node.js (GA), Go (GA), Python (Beta), Dart (Preview) | Python (GA), Go (GA), Java (GA), TypeScript (GA) |
| Design Philosophy | Developer explicitly controls the flow | LLM dynamically decides agent delegation |
| Deployment | Cloud Run, GKE, Vercel, or anywhere | Anywhere, with deep Gemini Enterprise Agent Platform integration |
The choice depends on the complexity of your problem and your team's preferred language. They're independent OSS frameworks running in parallel, not merged.
---
## Framework Overview
### Genkit
Genkit is a framework designed to simplify adding AI capabilities to your applications.
Originally developed by the Firebase team, it became an independent product after a Google reorganization and now sits alongside Flutter, Dart, and Go in the developer platforms organization.
Use plugins and templates to integrate various generative AI models and quickly build AI features.
**Key Features:**
- **Languages**:
- Node.js (GA Feb 2025)
- Go (GA Sep 2025)
- Python (Beta since Feb 2026)
- Dart (Preview)
- Java (community implementation `genkit-ai/genkit-java`, 1.0.0-SNAPSHOT under active development)
- **Runtime**: Deploy anywhere — Cloud Run, GKE, Vercel, on-premise, etc.
- **Model Support**: Gemini, OpenAI, Anthropic Claude, Ollama, and more via plugins
- **Developer Experience**: Local Developer UI for prompt testing, step-by-step traces, and schema validation
- **Multi-agent**: Beta support via the prompt-as-tool pattern
- **Middleware**: Cross-cutting extensions across Generate / Model / Tool layers (guardrails, caching, retries, HITL approval). Announced May 2026, available in TypeScript / Go / Dart (Python planned)
[Genkit Dart](https://github.com/genkit-ai/genkit-dart) is also under active development as a full Genkit SDK for Dart. With model plugins (`genkit_google_genai`, `genkit_firebase_ai`), flow/tool definitions, and server-side HTTP hosting via `genkit_shelf`, it offers capabilities comparable to the Node.js and Go SDKs — opening up possibilities for both server-side Dart and Flutter applications.
### Agent Development Kit (ADK)
ADK is a toolkit specialized for building sophisticated AI agent systems. It provides a foundation for complex multi-agent scenarios involving coordination between agents and external tool integrations.
**Key Features:**
- **Languages**:
- Python (GA May 2025, ~10 months ahead of the others; v2.0 is Python-only and in Beta phase)
- Go (v1.0 stable, Mar 2026)
- Java (v1.0 stable, Mar 2026)
- TypeScript (v1.0 stable, Apr 2026)
- **Runtime**: Deploy to any container environment — Cloud Run, GKE, etc. Deep integration with **Gemini Enterprise Agent Platform** (the evolution of Vertex AI, rebranded at Cloud Next 26 in April 2026) and its Agent Runtime (formerly Agent Engine)
- **Model Support**: Gemini via Agent Platform, plus other models through LiteLLM adapters
- **Developer Experience**: Local dev UI (`adk web`), CLI evaluation tool (`adk eval`)
- **Multi-agent**: First-class support for Agent, Tool, Sub-agent, and workflow orchestration
- **Enterprise Connectors**: 100+ connectors via ApplicationIntegrationToolset — Salesforce, ServiceNow, JIRA, SAP, and more. Built-in tools added since: Vertex AI Search Tool, UrlContextTool, SkillToolset, Spanner Admin Toolset, Apigee model integration
Companies like Renault Group, Box, and Revionics are already building systems with ADK.
---
## Key Differences
### Design Philosophy
These aren't competing frameworks — they operate at different layers.
A Google engineer working on Genkit summarized its role this way:
> Genkit focuses on "low-layer" AI APIs: models, tools, prompts, MCP, and developer tools like o11y tooling and playgrounds. Genkit lets you interact directly with LLMs, ships without hardcoded prompts by default, and its o11y tooling lets you see exactly what your agent is doing.
From this perspective:
- **Genkit** is a low-layer AI framework. It focuses on models, tools, prompts, MCP, and o11y. You can use it standalone to build agents, or as a foundation for higher-layer frameworks.
- **ADK** is a high-layer agent framework. It comes with batteries included — session management, memory, evaluation, and tight Gemini Enterprise Agent Platform integration.
The Genkit team has framed it on Discord as: ADK is purpose-built for agents and opinionated about how multi-agent systems are constructed, while Genkit is a general-purpose GenAI toolkit that takes fewer strong opinions on the specific construction of agents. In short: Genkit is flexible, ADK is specialized for multi-agent construction — pick by requirement.
### Comparison Table
| Aspect | Genkit | ADK |
| --- | --- | --- |
| **Primary Use Case** | General GenAI integration (chatbots, summarization, recommendations, RAG, etc.) | Complex agent systems with multiple collaborating agents |
| **Languages** | Node.js (GA), Go (GA), Python (Beta), Dart (Preview) | Python (GA), Go (GA), Java (GA), TypeScript (GA) |
| **Development Style** | Code-defined flows, explicit control | Agent-centric, LLM-driven orchestration |
| **Multi-agent** | Beta (prompt-as-tool pattern); Middleware for cross-cutting extensions | First-class (Sequential / Parallel / Loop; graph-based Workflow in v2.0 Beta) |
| **Deployment** | Cloud Run, GKE, Vercel, anywhere | Anywhere; best fit with Gemini Enterprise Agent Platform / Agent Runtime |
| **Model Flexibility** | Switch between Gemini / OpenAI / Anthropic / Ollama and more via Genkit plugins | Gemini-centric via Agent Platform, with other vendors via LiteLLM |
| **Maturity** | Node GA (Feb 2025), Go GA (Sep 2025), Python Beta (Feb 2026~), Dart Preview | Python GA (May 2025), Go / Java / TS reached v1.0 stable in Mar-Apr 2026, Python v2.0 in Beta |
### Architecture Patterns
#### Genkit: Flow Orchestration
Genkit uses a style where you explicitly define the workflow (flow).
You write the processing steps in code: "generate text with LLM A → process the result → execute function B → ...". Genkit provides unified APIs like `ai.generate()`, structured data output, and function calling through plugins, making these steps easy to implement concisely.
**TypeScript Example:**
```typescript
import { genkit } from 'genkit';
import { googleAI } from '@genkit-ai/google-genai';
const ai = genkit({ plugins: [googleAI()] });
const { text } = await ai.generate({
model: googleAI.model('gemini-2.5-flash'),
prompt: 'Why is Genkit awesome?'
});
```
**Go Example:**
```go
import (
"context"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/plugins/googlegenai"
)
func main() {
ctx := context.Background()
g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}))
resp, _ := genkit.Generate(ctx, g,
ai.WithModel(googlegenai.GoogleAIModel("gemini-2.5-flash")),
ai.WithPrompt("Why is Genkit awesome?"),
)
fmt.Println(resp.Text())
}
```
With function calling, the LLM can behave like a semi-autonomous agent that invokes defined functions as needed, but you still control the overall flow.
#### ADK: Agent-Centric Orchestration
ADK uses an agent-oriented architecture.
You define agents (Agent classes), each with its own role (instruction prompts) and available tools or sub-agents.
ADK supports multiple patterns for sub-agent coordination:
- **`sub_agents` pattern**: The LLM automatically generates `transfer_to_agent` calls to delegate control to sub-agents
- **`AgentTool` pattern**: Wrap sub-agents as tools that the root agent can invoke
Here's an example of the `AgentTool` pattern, where sub-agents have their own tools and are called as "tools" by the root agent:
```python
from google.adk.agents import Agent
from google.adk.tools import AgentTool, ToolContext
# --- Sub-agent 1: Goal Setting ---
def get_user_goal(tool_context: ToolContext) -> dict:
"""Retrieves the user's current health goal from session state."""
goal = tool_context.state.get("health_goal")
if goal is None:
return {"status": "not_set", "message": "No goal set yet."}
return {"status": "success", "health_goal": goal}
def set_user_goal(tool_context: ToolContext, goal_type: str, description: str) -> dict:
"""Sets the user's health goal in session state."""
tool_context.state["health_goal"] = {"goal_type": goal_type, "description": description}
return {"status": "success", "message": f"Goal set: {goal_type}"}
goal_setting_agent = Agent(
name="goal_setting_agent",
description="Handles health goal setting and retrieval.",
instruction="You help users set and check their health goals.",
tools=[get_user_goal, set_user_goal],
)
# --- Sub-agent 2: Meal Advisor ---
meal_advisor_agent = Agent(
model="gemini-2.5-flash",
name="meal_advisor_agent",
description="Provides meal recommendations based on user goals.",
instruction="You suggest meals considering the user's health goals and calorie targets.",
)
# --- Root Agent ---
root_agent = Agent(
model="gemini-2.5-flash",
name="health_advisor",
description="Main health advisor agent",
instruction="""You are a health advisor assistant.
## Your Role
- Understand user requests and delegate to appropriate sub-agents
- Use goal_setting_agent for goal-related queries
- Use meal_advisor_agent for meal recommendations
## Direct Response Cases
- Greetings: respond directly
- General health questions: respond directly
""",
tools=[
AgentTool(agent=goal_setting_agent),
AgentTool(agent=meal_advisor_agent),
],
)
```
The root agent delegates to appropriate sub-agents based on LLM reasoning. Each sub-agent has its own tools and instructions to handle specialized tasks.
ADK also provides workflow control agents:
- **SequentialAgent**: Run agents in sequence
- **ParallelAgent**: Run agents in parallel
- **LoopAgent**: Repeat until a condition is met
#### ADK 2.0 (Beta): Graph-based Workflow Runtime
In April 2026, ADK entered v2.0 Beta phase. The core of v2.0 is a **graph-based Workflow runtime** that lets you express task routing, fan-out / fan-in, loops, retries, human-in-the-loop approval, and nested workflows explicitly as a graph. Combined with the new Task API, agent-to-agent delegation can carry typed input / output contracts — handling more complex branching than Sequential / Parallel / Loop allows.
That said, v2.0 is **Python-only** and Beta at the time of writing. v1.x continues for production use, and v1.x and v2.0 can coexist in the same process (compatibility maintained). You don't need to migrate to v2.0 right now — if Sequential / Parallel / Loop covers your needs, staying on v1.x is fine. v2.0 becomes a candidate when complex graph orchestration enters the picture.
---
## Decision Guide
| Scenario / Condition | Recommendation | Reason |
| --- | --- | --- |
| Quickly add simple AI features to an app | Genkit | Chatbots, summarization, RAG. Prioritize development speed |
| Complex AI system with multiple specialized agents | ADK | Multiple data sources/tools, need role separation |
| Team primarily writes TypeScript / JavaScript | Genkit | Node.js support, leverage web frontend experience |
| Team primarily writes Python | ADK | Leverage ML and data processing experience. Python is GA |
| Team primarily writes Go | Either works | Genkit Go is GA, ADK Go is available |
| Need to connect to Google Cloud data / enterprise systems | ADK | Rich connectors for BigQuery, Salesforce, ServiceNow, etc. |
| Want flexibility for various AI use cases in the future | Genkit | High extensibility through plugins |
### Choosing by Language
Now that ADK supports Python, TypeScript, Go, and Java, and Genkit supports Node.js, Go, Python, and Dart, language preference alone rarely decides the choice.
However, maturity varies:
| Language | Genkit | ADK |
| --- | --- | --- |
| **TypeScript/Node.js** | GA | v1.0 stable (Apr 2026) |
| **Go** | GA | v1.0 stable (Mar 2026) |
| **Python** | Beta (since Feb 2026) | v1.x GA + v2.0 Beta |
| **Dart** | Preview | Not supported |
| **Java** | Community implementation (1.0.0-SNAPSHOT) | v1.0 stable (Mar 2026) |
If your team writes Python and prioritizes production stability, ADK (v1.x GA since May 2025, well-proven) is the natural choice — and v2.0 Beta is on the table if you want to explore graph-based orchestration. For Go teams, both Genkit Go and ADK Go are at v1.x stable, so the decision comes down to use case (embedding AI / agentic features into an app vs building a standalone multi-agent system). Dart / Flutter teams: Genkit Dart (Preview) is the realistic option. Java teams: ADK v1.0 is the solid pick, and Genkit Java is moving at 1.0.0-SNAPSHOT if you want to try Genkit patterns in Java.
---
## Developer Tooling Integration
Both sides are wiring up integrations with AI development tools (Gemini CLI, Claude Code, Codex, and others) so you can drive agent development through natural language.
**Genkit**: The **Genkit Extension for Gemini CLI** ships MCP tools for flow execution, doc lookup, and trace analysis, plus code generation aligned with Genkit patterns. See the repo for install instructions.
**ADK**: **Agents CLI** (open source, Apache-2.0) handles scaffolding / eval / deploy / observability for ADK agents. Subcommands like `scaffold`, `eval run`, `deploy`, `publish gemini-enterprise` plus seven skill bundles that AI coding tools can load for ADK-specific workflows. See the official docs for installation and setup.
Both push agent development toward "let the tools do it" — pick them up once you've settled on Genkit or ADK and want to accelerate the build loop.
---
## Learning Path and Combination Patterns
### From Low-Layer to High-Layer
A common recommendation shared in the Genkit community is to follow Anthropic's "[Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)" guidance.
When you're starting out, it often makes sense to begin with a low-layer toolkit like Genkit to deeply understand models, tools, prompts, and o11y, then adopt a high-layer framework like ADK based on your application's requirements.
Once you have this foundation, you can work with ADK or any other framework.
### Split by Use Case First, Then Combine
Rather than thinking of these as "complementary layers," it's closer to Google's current public stance to split by use case first. On social media, the framing often goes:
> Have an app (web, mobile, etc)? Want to add agentic features to it? Use Genkit!
>
> Building complex, standalone, multi-agent systems? E.g. on GCPs Agent Platform? Use ADK.
- **Choose Genkit when**: You have an existing app (web / mobile / API backend) and want to **add** AI / agentic features — chat, summarization, RAG, tool calls. Easier to get started, more ergonomic for app developers
- **Choose ADK when**: You're building a **standalone** complex system on Agent Platform where multiple agents collaborate, with enterprise requirements (connectors, governance, evaluation) at the forefront
If you want both, you can combine them — e.g., call a separately-deployed ADK agent from a Genkit-powered API backend. But you don't have to design for "combination first." Figure out which side your use case leans toward, then bridge later when the need arises.
---
## Summary
Think of Genkit and ADK as complementary layers rather than competing choices.
| Layer | Framework | Focus |
| --- | --- | --- |
| Low-layer | Genkit | Models, tools, prompts, MCP, o11y |
| High-layer | ADK | Session management, memory, evaluation, multi-agent orchestration |
Start with Genkit to understand AI fundamentals, then consider ADK when requirements get complex. Or combine them — use Genkit as a foundation with ADK on top. This flexibility is a strength of Google's AI framework ecosystem.
---
## Resources
### Genkit
### ADK
---
## Stopping Spec Drift in the Era of Fast AI Coding: Building an OSS Called jev-spec
URL: https://koborin.ai/tech/jev-spec-introduction/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import jevSpecIntroductionOg from '../../../assets/og/jev-spec-introduction.png';
## The Reality of Fast AI Coding and Spec Drift
About six months ago, I wrote [an article](/tech/contextlint-introduction/) about contextlint, a linter that statically analyzes the consistency of Markdown documents.
In Specification-Driven Development (SDD), teams translate business "requirements" into system behaviors as "specifications (acceptance criteria)", and further flesh them out into "designs" such as table schemas and screen layouts, capturing them in Markdown and feeding them into AI coding agents like Cursor or Claude Code.
AI writes code at breathtaking speed. However, as the velocity of code generation increases, the frequency with which human developers reread original specifications drops proportionally. In day-to-day code reviews, attention naturally gravitates toward immediate diffs and runtime smoke tests. Reviewing whether every sentence in the specification still holds against the generated code's behavior is rarely feasible. As a result, discrepancies between specifications and code accumulate unnoticed beneath the surface.
Static analysis (contextlint and similar tools) can confirm that a specification contains an entry such as `REQ-AUTH-02` (used here as an example of a requirement or specification identifier), follows the expected format, and is properly referenced across documents. But whether `src/auth/session.ts` still behaves according to `REQ-AUTH-02` cannot be verified by parsing document syntax alone.
To counter this drift, some teams create custom slash commands or Agent Skills in Cursor or Claude Code to periodically crawl the codebase and check alignment against specifications. In practice, this means asking an agent: "Check whether this code satisfies all requirements in the spec and flag any omissions."
Yet running this approach on every commit is impractical. Stuffing substantial codebases and full specifications into prompts and running general-purpose LLM agents takes minutes per run and consumes significant token costs. It is far too slow and expensive to act as a pre-commit hook gating `git commit`. Consequently, drift detection remains an occasional, heavy audit run only when someone remembers, while discrepancies between specifications and code continue to accumulate unnoticed during daily development.
"Could we catch the drift between specifications and code in tenths of a second on every single commit?" This question led me to develop the OSS `jev-spec`.
## The Limits of Traditional LLMs and the Arrival of Jev
When attempting to build a pre-commit gate that reconciles code against specifications, traditional LLMs face structural bottlenecks.
General-purpose LLMs are fundamentally autoregressive: they choose the next token conditioned on tokens already produced. JSON mode and Structured Outputs still sample the output as a token sequence that fits the schema. The schema only constrains which tokens are legal next. It does not change the generation method. Longer output increases wait time and output-token cost. Wait time also includes input processing, reasoning-token generation, and the first schema compilation. Billing is not limited to output tokens. Input tokens are charged separately. To avoid breaking a developer's flow during a local pre-commit check, verification must finish in under a second. High latency and recurrent token billing make traditional LLMs ill-suited for every commit.
A fresh architectural direction arrived with Jev, a model developed by TypeSafe AI. TypeSafe categorizes Jev as a "System One model."
To describe it accurately: Jev does not perform free-form autoregressive text generation or sequential token decoding. Instead, it directly evaluates given context or state (text, data, application state, and similar inputs) in a single pass and outputs typed decision primitives: well-calibrated probabilities and scores. jev-spec uses this capability by passing specifications and code as context and reading probabilities for each spec item's Rubric.
Because it does not generate text token by token, there is no waiting on output decoding, and output tokens are free. Input token costs are negligible ($0.042 per million input tokens), and response times clock in at just 70 to 400ms.
Jev provides three core decision primitives:
- **`noul` (Boolean proposition)**
evaluates a proposition answerable with yes or no, returning a probability between 0 and 1
- **`choice` (Categorical distribution)**
evaluates a discrete probability distribution across declared, mutually exclusive options (such as `compliant`, `vulnerable`, or `inconclusive`)
- **`score` (Ordinal rubric)**
evaluates an ordered rubric (such as a multi-tier maturity scale from stub to production-grade), returning an expected score and level probabilities
Because Jev's output is numeric and grounded from the start, there is no need to parse natural language. Comparing the returned probability against a deterministic threshold (such as `minProbability: 0.85`) produces an immediate pass or fail verdict.
:::note
jev-spec is an independent open-source project that I develop on my own. It is not affiliated with or endorsed by TypeSafe AI. Descriptions of Jev's properties and pricing reflect the official documentation as of September 2026.
:::
## Expanding Horizons: Jev's Broader Potential and the Genesis of jev-spec
It is worth emphasizing that Jev was not designed exclusively for specification verification.
Observing the developer community on [X](https://x.com), engineers are leveraging this ultra-fast, low-cost decision primitive to build software interactions that were previously unviable.
Marcus Lowe's [smart clipboard demo](https://x.com/marcus_lowe/status/2101886533146161275) instantly evaluates copied text, recognizes whether it contains contact details or personal credentials, and routes the content to corresponding form fields in milliseconds. [OkinaAudio's creative audio workflow for Ableton](https://x.com/okinaaudio/status/2101156959546450049) demonstrates fast audio and UI interaction.
Unlike open-ended text generation models, executing fast, inexpensive, type-safe decisions in milliseconds opens up distinct possibilities for software interaction. As an engineer, watching this architectural direction unfold is genuinely exciting.
Seeing Jev's characteristics firsthand sparked an immediate question from my background building contextlint:
"If we have a decision primitive this fast and lightweight, could we verify whether code satisfies the behavior and requirements written in specifications on every single git commit?"
Exploring whether this fast decision model could be applied to Specification-Driven Development is what motivated me to start building jev-spec.
## How jev-spec Works
The operational pipeline in jev-spec consists of four steps:
1. **Read the spec**
extracts the statements to verify from a Markdown specification based on sections, headings, or specification/requirement IDs
2. **Read the code**
loads the source files implementing that specification
3. **Evaluate rubrics**
submits focused evaluation questions (Rubrics) per spec item to Jev, which evaluates the spec and code as context in a single pass and returns probabilities in parallel
4. **Compare**
evaluates the probabilities against defined threshold assertions, stopping the build as a validation error (check failure) if any assertion fails
Each evaluation question is called a **Rubric**, and the comparison with a threshold is an **Assertion**. While binary verification with `noul` is most common for specs, the DSL also supports `choice` and `score`.
For speed and rigorous type safety, jev-spec adopts **TypeScript 7**, powered by the new Go compiler. Furthermore, it provides official dual runtime support for both Node.js (22+) and Bun.
## Basic Usage
Getting started with jev-spec in your project is straightforward.
Agent Skills clients (Claude Code, Cursor, Codex, Antigravity CLI, and GitHub Copilot) can install a skill with the following command:
```zsh
gh skill install nozomi-koborinai/jev-spec
```
| Skill | Role |
| --- | --- |
| `jev-spec-init` | Maps the spec to the code and drafts the Rubrics |
| `jev-spec-fix` | Helps fix a failed check |
### 1. Installation
Add `jev-spec` to your devDependencies using your package manager of choice:
```bash
npm install -D jev-spec
# or
bun add -d jev-spec
```
### 2. Configuration (jev.config.ts)
Create a `jev.config.ts` file in your project root and declare the mapping between specifications and code in `targets`:
```typescript
import { defineConfig, noul } from 'jev-spec';
export default defineConfig({
client: { model: 'jev-1.13.0' },
targets: {
auth: {
specPath: 'docs/specs/auth-requirements.md',
codePaths: ['src/auth/**/*.ts', '!src/auth/**/*.test.ts'],
rubrics: {
'REQ-AUTH-01': noul(
'Is the signature of a session token checked before access to a protected resource is granted?'
),
'REQ-AUTH-02': noul('Is a token rejected when its ID is on the revocation list?'),
},
assertions: {
'REQ-AUTH-01': { minProbability: 0.85 },
'REQ-AUTH-02': { minProbability: 0.85 },
},
},
},
});
```
Key options configured in `defineConfig` include:
- **`specPath`**
path to the target Markdown specification file
- **`codePaths`**
glob patterns for code files implementing that specification (with negation support to exclude test files)
- **`rubrics`**
spec and behavior checks written as natural-language questions using `noul(...)` (keyed by IDs such as `REQ-AUTH-01` or target spec item names)
*Note: Keying by IDs is merely one example. Even if your project does not strictly assign requirement IDs, you can organize rubrics with arbitrary key names.*
- **`assertions`**
minimum confidence threshold for passing (such as `minProbability: 0.85`)
### 3. Setting the API Key
Export your TypeSafe AI API key as an environment variable:
```bash
export TYPESAFE_AI_API_KEY="your-api-key"
```
### 4. Running Checks
Once configured, run checks from the CLI.
To verify all targets across the entire repository:
```bash
npx jev-spec check
# or
bunx jev-spec check
```
To verify only targets affected by staged Git changes before committing, use the `--staged` flag. Targets unaffected by the staged diff are automatically `SKIPPED`:
```bash
npx jev-spec check --staged
```
The CLI report prints clear, structured feedback:
```text
$ npx jev-spec check
=== jev-spec Check Report ===
Target: auth [✖ FAILED]
Spec files: docs/specs/auth-requirements.md
Code files: src/auth/session.ts
Model: jev-1.13.0
✔ REQ-AUTH-01: probability: 0.97
✖ REQ-AUTH-02: probability: 0.08
└─ Violation: Probability 0.08 is below minimum threshold 0.85
Overall: ✖ CHECKS FAILED
$ echo $?
1
```
Displaying comparison results between probabilities and thresholds per spec item (ID) makes it straightforward to identify exactly which specification and code have drifted.
### 5. Workflow Integration
jev-spec shines most when embedded into your routine development workflow as an everyday gate:
- **Staged checks in pre-commit hooks**
using tools like husky or simple-git-hooks, run `npx jev-spec check --staged` on every commit. Because it evaluates only modified targets in milliseconds, it catches drift before code is committed without interrupting your flow
- **Full checks in CI**
in your CI pipeline (such as GitHub Actions), run a full `npx jev-spec check` across all targets to guarantee spec compliance before merging pull requests
## What Real-World Measurements Revealed
Gating builds on a probabilistic model raised questions about cost and reproducibility. To investigate, I conducted empirical measurements on jev-spec's own repository, which maintains its own specs (`docs/specs/`). The numbers below were recorded on September 21, 2026 using `jev-1.13.0`.
### Measured Cost
Checking all 8 targets and 22 Rubrics across the entire repository completed in approximately 4 seconds. Individual targets took between 0.3 and 0.9 seconds. CLI startup alone took roughly 0.1 seconds on Node.js (and was even faster on Bun); the vast majority of runtime was spent on network requests.
The estimated cost reported by the CLI was approximately $0.0006 per full run. Rather than stuffing the entire codebase into context, jev-spec transmits only the relevant spec excerpt and a handful of files per target. Jev evaluates all Rubrics in that target in a single pass. The cost of running this on every commit is negligible.
### Reproducibility and Model Pinning
Repeating identical Rubrics over identical code five times yielded a confidence variance of 0.00 to 0.03 on clean code. A probability moving from 0.96 to 0.95 remains safely above the example threshold of 0.85 (configured as `minProbability` in the settings).
Thresholds are not hardcoded values; they can be configured individually in the settings based on the nature of the specifications and requirements. In addition, because aliases such as `latest` can resolve to a newer model version and cause unintended shifts in evaluations, specifying an explicit versioned ID (such as `jev-1.13.0`) in `client.model` is recommended. Pinning the model ensures consistent evaluations over time and prevents unexpected behavioral changes, with the report explicitly displaying the model ID used.
## Static Analysis vs. Decision Models, and Conclusion
Deploying jev-spec effectively requires recognizing its operational boundaries:
- **A check, not a mathematical proof**
passing indicates a probability surpassed an empirical threshold, not formal correctness. Internal terminology deliberately uses "check" rather than "verify"
- **English is officially recommended**
while official documentation recommends English and notes that [CJK languages may not reach the same accuracy](https://docs.typesafe.ai/models#language-support), in practice Jev also understands Japanese specifications and Rubrics quite well and can evaluate them effectively
- **Adversarial code comments can bias evaluations**
comments stating "this function satisfies the specification" can skew decisions. TypeSafe lists this as a known model limitation
- **Arithmetic, dates, and multi-step reasoning are poor fits**
counting items or comparing calendar dates should be verified via standard unit tests rather than Rubrics
The complementary roles of contextlint and jev-spec can be summarized as follows:
| Dimension | contextlint | jev-spec |
| :--- | :--- | :--- |
| Scope | Consistency across documents | Alignment between specs and code |
| Method | Rule-based static analysis | Model Rubric evaluation and threshold comparison |
| Determinism | Deterministic | Probabilistic |
| AI Dependency | None | Yes (TypeSafe AI Jev) |
In an era where AI generates code at unprecedented speed, guarding against specification drift without slowing down development rhythm is essential.
jev-spec started as a personal experiment, but if you maintain specifications in Markdown and worry about drift from code, consider giving it a try.
---
## koborin.ai Architecture
URL: https://koborin.ai/tech/koborin-ai-architecture/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import gcsImage from '../../../assets/tech/koborin-ai-architecture/gcs.png';
import dnsImage from '../../../assets/tech/koborin-ai-architecture/dns.png';
import iap1Image from '../../../assets/tech/koborin-ai-architecture/iap-1.png';
import iap2Image from '../../../assets/tech/koborin-ai-architecture/iap-2.png';
import koborinAiArchitectureOg from '../../../assets/og/koborin-ai-architecture.png';
This site, [koborin.ai](https://koborin.ai), is my personal site (Technical Garden) for publishing technical notes and thoughts.
This article introduces the overall architecture and design philosophy of the site. I'll explain why I chose this setup from both infrastructure and frontend perspectives.
---
## Overall Architecture
Let's start with a bird's-eye view of the site's structure.
```mermaid
---
title: "Google Cloud Project"
---
flowchart LR
subgraph PULUMI_STATE["Pulumi State"]
STATE_SHARED["shared"]
STATE_DEV["dev"]
STATE_PROD["prod"]
end
subgraph SHARED["Shared"]
APIS["APIs"]
ARTIFACT["Artifact Registry"]
WIF["WIF"]
end
subgraph DNS["Cloudflare DNS"]
DEV_DOMAIN["dev.koborin.ai"]
PROD_DOMAIN["koborin.ai"]
end
subgraph LB["HTTPS LB"]
STATIC_IP["Static IP"]
SSL_CERT["SSL Cert"]
URL_MAP["URL Map"]
HTTPS_PROXY["HTTPS Proxy"]
end
subgraph DEV_BACKEND["Dev Backend"]
DEV_IAP["IAP"]
DEV_NEG["NEG"]
DEV_CR["Cloud Run Dev"]
end
subgraph PROD_BACKEND["Prod Backend"]
PROD_NEG["NEG"]
PROD_CR["Cloud Run Prod"]
end
subgraph CICD["GitHub Actions"]
GH_WIF["WIF Auth"]
PULUMI_SA["Pulumi SA"]
end
STATE_SHARED -.-> SHARED
STATE_SHARED -.-> LB
STATE_DEV -.-> DEV_BACKEND
STATE_PROD -.-> PROD_BACKEND
ARTIFACT -.-> DEV_CR
ARTIFACT -.-> PROD_CR
DEV_DOMAIN --> STATIC_IP
PROD_DOMAIN --> STATIC_IP
STATIC_IP --> SSL_CERT
SSL_CERT --> HTTPS_PROXY
HTTPS_PROXY --> URL_MAP
URL_MAP --> DEV_IAP
URL_MAP --> PROD_NEG
DEV_IAP --> DEV_NEG
DEV_NEG --> DEV_CR
PROD_NEG --> PROD_CR
GH_WIF -.-> WIF
WIF -.-> PULUMI_SA
PULUMI_SA -.-> PULUMI_STATE
```
### Terminology
| Abbreviation | Full Name | Description |
| --- | --- | --- |
| LB | [Application Load Balancer](https://cloud.google.com/load-balancing/docs/load-balancing-overview) | Distributes requests to appropriate backends |
| NEG | [Serverless network endpoint group](https://cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts) | Connection point to Cloud Run services |
| WIF | [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation) | Authentication from GitHub Actions to Google Cloud |
| IAP | [Identity-Aware Proxy](https://cloud.google.com/iap/docs/concepts-overview) | Access control using Google accounts |
### Key Points
- **Request path**: DNS (Cloudflare) → Global HTTPS LB → Cloud Run (dev/prod)
- **Dev environment is protected by IAP** + `X-Robots-Tag: noindex` to prevent search engine indexing
- **CI/CD**: GitHub Actions → Workload Identity Federation → Cloud Build / Pulumi
---
## Design Philosophy
Here's what I considered when building this site, from both infrastructure and frontend perspectives.
### Infrastructure Design
#### Single Google Cloud Project + 3 Stacks
In healthcare or financial systems, strict requirements often apply, making it common to **separate GCP projects entirely**:
- Complete separation of audit trails and logs
- Strict permission separation for "who can access the admin console"
- Compliance requirements
For a personal site, such strictness is unnecessary.
However, **as a developer, I still want a dev/prod distinction**. Testing directly in production is risky, and I want to verify changes before releasing them.
So I chose to **keep costs and operational burden low by using a single GCP project**, while separating Cloud Run services into dev and prod.
#### State in IaC
IaC tools like Pulumi and Terraform manage a **State** file to track "which resources are currently deployed."
State serves the following purposes:
- **Records current resource state**: Tracks whether a resource is created, updated, or deleted
- **Baseline for diff detection**: Compares code changes against State to determine what to create/update/delete
- **Synchronization for team development**: Storing State remotely allows multiple people to operate the same infrastructure
For this site, I store Pulumi's State in GCS (Google Cloud Storage). Since each stack has its own separate file, the structure looks like this:
`shared.json`, `dev.json`, and `prod.json` are the State files for each stack.
#### Responsibilities of shared / dev / prod
| Stack | Responsibility |
| --- | --- |
| `shared` | Common resources (LB, WIF, Artifact Registry, Managed SSL Cert) |
| `dev` | Dev Cloud Run service `koborin-ai-web-dev` |
| `prod` | Prod Cloud Run service `koborin-ai-web-prod` |
Here, `shared` functions less as an "environment" and more as a **State boundary for grouping common resources**.
Since the LB handles host-based routing for both environments, it's naturally a shared resource. The same applies to Artifact Registry and Workload Identity Federation, which are used across environments.
#### IaC Framework Migration: CDKTF → Pulumi Go
The IaC for this site was originally written in **CDK for Terraform (CDKTF)**.
I liked being able to manage infrastructure as code while defining Terraform resources **type-safely** in TypeScript.
However, CDKTF was **sunset / archived** on December 10, 2025.
> Terraform CDK (CDKTF) will sunset and be archived on December 10, 2025. HashiCorp, an IBM Company, will no longer maintain or develop the project after that date.
I chose **Pulumi Go** as the migration target. Here's why:
1. **I like Go**: I've been writing more Go lately, and it's nice to unify infrastructure code in Go as well
2. **Same "write infrastructure in code" paradigm**: Like CDKTF, you can define infrastructure using a programming language
3. **Low migration cost**: The resource structure and concepts are similar, making migration relatively straightforward
### Frontend Design
#### No Need for Custom API / DB
For a personal site, I considered the following requirements:
- Write and publish technical notes and thoughts in Markdown (MDX)
- No need for dynamic data fetching or user authentication
- Keep operations as simple as possible
With these requirements, **there's no need for a custom API server or database**. Static content generation is sufficient.
#### GitHub as CMS
As an engineer myself, I don't need a CMS (like WordPress or Notion). **Managing MDX files directly in a GitHub repository** is enough.
- Article creation and editing are version-controlled with Git
- PR-based reviews and previews are possible
- CI/CD automatically builds and deploys
This eliminates the need for a separate database, reducing operational costs.
#### Adopting Starlight
When selecting a framework, I referenced the site structure of [genkit.dev](https://genkit.dev). genkit.dev uses Starlight and provides a polished UX as a documentation site.
Reasons for choosing Starlight:
- **Astro-based**: Specialized for static site generation with excellent performance
- **Multilingual support**: Can provide content in both English and Japanese
- **Documentation-focused features**: Sidebar, search, dark mode included by default
- **MDX support**: Can use React components within Markdown
---
## Request Path
From here, I'll explain the flow when a user accesses the site.
### 1. DNS Resolution (Cloudflare)
The domain was originally acquired through Cloudflare, so I configured **Cloudflare A records** to point `koborin.ai` and `dev.koborin.ai` to the GCP static IP.
> Note: DNS is configured manually in the Cloudflare console.
### 2. Global HTTPS Load Balancer
When traffic hits the static IP, the **Global HTTPS Load Balancer** receives it.
The LB uses a **URL Map** to route traffic based on the hostname:
- `koborin.ai` → Prod Backend Service → Cloud Run `koborin-ai-web-prod`
- `dev.koborin.ai` → Dev Backend Service → Cloud Run `koborin-ai-web-dev`
### 3. IAP Access Control
When accessing `dev.koborin.ai`, **Identity-Aware Proxy (IAP)** performs authentication at the front.
Only my Google account is allowed access; all other users are blocked.
State
Screenshot
Dev access
Dev access denied
Additionally, dev environment responses include the `X-Robots-Tag: noindex, nofollow` header, so even if someone could access it, it wouldn't be indexed by search engines.
---
## Site Features
Here, I'll introduce the features this site provides.
### llms.txt
This site provides LLM context files at `/llms.txt`.
| File | Content |
| --- | --- |
| `/llms.txt` | Index with links to all variants |
| `/llms-full.txt` | All English articles (with full Markdown body) |
| `/llms-ja-full.txt` | All Japanese articles (with full Markdown body) |
| `/llms-tech.txt`, `/llms-ja-tech.txt` | Tech category only |
| `/llms-life.txt`, `/llms-ja-life.txt` | Life category only |
```text
# koborin.ai
> Personal site + technical garden.
## Full (with article content)
- https://koborin.ai/llms-full.txt
- https://koborin.ai/llms-ja-full.txt
## By Category
### Tech
- https://koborin.ai/llms-tech.txt
- https://koborin.ai/llms-ja-tech.txt
### Life
- https://koborin.ai/llms-life.txt
- https://koborin.ai/llms-ja-life.txt
```
OSS projects often publish `llms.txt` to help LLMs understand "what the project is and how to use it." This is incredibly useful, providing context for LLMs to correctly work with libraries and frameworks.
For a personal site, there's a different use case. As my thoughts and technical notes accumulate over time (at least that's the plan... I hope to keep it up...), it might be useful in the future to have an LLM reference "what I was thinking in the past."
With that possibility in mind, I publish the full article body in this format.
### Engagement Features
Article pages include features to encourage engagement with readers.
#### Giscus (Comments)
[Giscus](https://giscus.app) is a comment system that uses GitHub Discussions as its backend.
- Readers can log in with their GitHub account to comment and react
- Comments are stored in GitHub Discussions, so no separate database is needed
- Automatically syncs with the site's dark/light mode
#### Share Buttons
Buttons are provided for sharing articles on social media:
- X (formerly Twitter)
- Bluesky
- Mastodon
- Hatena Bookmark
- Copy Link
These features are displayed **only on article pages with `publishedAt` in the frontmatter**. They don't appear on index pages or general documentation pages.
---
## CI/CD
CI/CD is implemented with GitHub Actions.
```mermaid
flowchart TB
subgraph PR["Pull Request"]
appCI["app-ci.yml"]
planInfra["plan-infra.yml"]
end
subgraph Merge["main merge"]
appRelease["app-release.yml"]
releaseInfra["release-infra.yml"]
end
subgraph Build["Build"]
cloudBuild["Cloud Build"]
artifactRegistry["Artifact Registry"]
end
subgraph Deploy["Pulumi Deploy"]
configImageUri["config set imageUri"]
pulumiUp["pulumi up"]
end
subgraph CloudRun["Cloud Run"]
devService["dev"]
prodService["prod"]
end
appRelease --> cloudBuild
cloudBuild --> artifactRegistry
artifactRegistry --> configImageUri
configImageUri --> pulumiUp
pulumiUp --> devService
pulumiUp --> prodService
releaseInfra --> pulumiUp
```
### App Deployment
| Trigger | Workflow | Actions |
| --- | --- | --- |
| On PR | `app-ci.yml` | Validate lint / typecheck / build |
| On main merge | `app-release.yml` | Cloud Build → Artifact Registry → `pulumi up` |
| `app-v*` tag | `app-release.yml` | Same as above (deploys to prod) |
### Infra Deployment
| Trigger | Workflow | Actions |
| --- | --- | --- |
| On PR | `plan-infra.yml` | `pulumi preview` to review changes |
| On main merge | `release-infra.yml` | `pulumi up` to apply infrastructure |
| `infra-v*` tag | `release-infra.yml` | Same as above (applies to prod) |
Both App and Infra deployments ultimately **update Cloud Run through Pulumi**.
Instead of using the `gcloud run deploy` command directly, we use the following flow:
1. Cloud Build builds the Docker image and pushes it to Artifact Registry
2. `pulumi config set imageUri ` updates the Pulumi configuration
3. `pulumi up` updates the Cloud Run service with the new image
This approach ensures that **infrastructure state always matches Pulumi's State**. Running `gcloud run deploy` manually could cause drift from the State, but going through Pulumi eliminates that concern.
### CI Optimization by Change Type
This site adjusts CI execution scope based on the nature of PR changes.
This approach is inspired by the distinction between **Behavior Change** and **Structure Change** that t-wada introduced at the Developer Productivity Conference 2025.
The original source is Kent Beck's book "Tidy First?", which proposes categorizing code changes into "behavior changes" and "structure changes" to reduce review burden and clarify the intent of changes.
#### Lessons from a Previous Project
In a previous full-stack project at work, I built E2E tests with Playwright as guardrails for AI agents.
That project also separated `app` and `infra`, with a rule that CI would run whenever code under `app/` changed. However, after integrating E2E tests into CI, **execution time ballooned to 13 minutes**, turning CI into a significant bottleneck.
The problem was that even for changes that clearly had no UI impact (like adding model fields or refactoring), full E2E tests would run every time. **The lesson learned: test scope should be adjusted based on the nature of the change.**
#### Label-Based Branching
Based on this experience, koborin.ai uses PR labels to branch CI execution steps.
| Label | Meaning | CI Behavior |
| --- | --- | --- |
| `change:behavior` | Behavior change (UI/output/config changes) | Full checks (lint + build + audit) |
| `change:structure` | Structure change (refactoring/formatting) | Lightweight checks (lint only) |
GitHub Actions determines the change type from PR labels and branches accordingly:
```yaml
- name: Determine change type (labels)
id: change-type
run: |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'change:structure') }}" == "true" ]]; then
echo "type=structure" >> "$GITHUB_OUTPUT"
else
echo "type=behavior" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.change-type.outputs.type == 'behavior'
working-directory: app
run: npm run build
```
The default is `behavior` (full checks), switching to lightweight checks only when the `change:structure` label is explicitly applied.
#### Documentation for AI Agents
This change type distinction is also used when AI agents create PRs.
By documenting definitions like the following in `AGENTS.md`, AI can judge the appropriate label based on the change content:
- **Behavior Change**: Content changes under `app/src/content/docs/`, changes to `astro.config.mjs` or `Dockerfile`, Pulumi stack changes, etc.
- **Structure Change**: Updates to `README.md` or `AGENTS.md`, comment-only changes, refactoring, etc.
While it will take time for this workflow to become fully established, **adjusting CI scope based on change nature** has proven effective for improving developer experience.
### Workload Identity Federation
All workflows use **Workload Identity Federation**, so there's no need to store service account keys in GitHub Secrets.
GitHub Actions uses OIDC tokens to authenticate with Google Cloud and impersonate a pre-configured service account.
---
## Implementation Details
From here, I'll introduce specific implementation details. Those interested can expand the accordions to see the code.
### Stack-Based Infrastructure Implementation
#### infra/stacks/shared.go
Defines common resources:
- **API Enablement**: Cloud Run, Compute, IAM, Artifact Registry, IAP, etc.
- **Artifact Registry**: Container image storage
- **Global Static IP**: PREMIUM tier static IP
- **Managed SSL Certificate**: Multi-domain certificate for `koborin.ai` and `dev.koborin.ai`
- **URL Map**: Host-based routing
- **Backend Services**: dev (IAP enabled) and prod (no IAP)
- **Serverless NEG**: Connection to Cloud Run services (references service name as string to avoid circular dependencies)
- **Workload Identity Federation**: For authentication from GitHub Actions
View `shared.go` code
```go
package stacks
import (
"strings"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/artifactregistry"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/iam"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/iap"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
func Shared(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
gcpCfg := config.New(ctx, "gcp")
projectID := gcpCfg.Require("project")
projectNumber := cfg.Require("projectNumber")
iapUser := cfg.Require("iapUser")
oauthClientID := cfg.Require("oauthClientId")
oauthClientSecret := cfg.RequireSecret("oauthClientSecret")
// Enable Required APIs
requiredAPIs := []string{
"run.googleapis.com",
"compute.googleapis.com",
"iam.googleapis.com",
"cloudresourcemanager.googleapis.com",
"artifactregistry.googleapis.com",
"cloudbuild.googleapis.com",
"iap.googleapis.com",
"monitoring.googleapis.com",
"logging.googleapis.com",
"certificatemanager.googleapis.com",
}
var apiServices []pulumi.Resource
for _, api := range requiredAPIs {
logicalName := "api-" + strings.ReplaceAll(api, ".", "-")
svc, err := projects.NewService(ctx, logicalName, &projects.ServiceArgs{
Project: pulumi.String(projectID),
Service: pulumi.String(api),
DisableDependentServices: pulumi.Bool(false),
DisableOnDestroy: pulumi.Bool(false),
})
if err != nil {
return err
}
apiServices = append(apiServices, svc)
}
// Artifact Registry
_, err := artifactregistry.NewRepository(ctx, "artifact-registry", &artifactregistry.RepositoryArgs{
Project: pulumi.String(projectID),
Location: pulumi.String("asia-northeast1"),
RepositoryId: pulumi.String("koborin-ai-web"),
Description: pulumi.String("Container images for koborin.ai web application (dev/prod)"),
Format: pulumi.String("DOCKER"),
DockerConfig: &artifactregistry.RepositoryDockerConfigArgs{
ImmutableTags: pulumi.Bool(true),
},
}, pulumi.DependsOn(apiServices))
if err != nil {
return err
}
// Global Static IP
staticIP, err := compute.NewGlobalAddress(ctx, "global-ip", &compute.GlobalAddressArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-global-ip"),
AddressType: pulumi.String("EXTERNAL"),
IpVersion: pulumi.String("IPV4"),
Description: pulumi.String("Static IP for koborin.ai HTTPS load balancer"),
}, pulumi.DependsOn(apiServices))
if err != nil {
return err
}
// Dev Environment Backend
devNEG, err := compute.NewRegionNetworkEndpointGroup(ctx, "dev-neg", &compute.RegionNetworkEndpointGroupArgs{
Project: pulumi.String(projectID),
Region: pulumi.String("asia-northeast1"),
Name: pulumi.String("koborin-ai-dev-neg"),
NetworkEndpointType: pulumi.String("SERVERLESS"),
CloudRun: &compute.RegionNetworkEndpointGroupCloudRunArgs{
Service: pulumi.String("koborin-ai-web-dev"),
},
}, pulumi.DependsOn(apiServices))
if err != nil {
return err
}
devBackend, err := compute.NewBackendService(ctx, "dev-backend", &compute.BackendServiceArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-dev-backend"),
Protocol: pulumi.String("HTTP"),
LoadBalancingScheme: pulumi.String("EXTERNAL_MANAGED"),
TimeoutSec: pulumi.Int(30),
CustomResponseHeaders: pulumi.StringArray{pulumi.String("X-Robots-Tag: noindex, nofollow")},
Backends: compute.BackendServiceBackendArray{
&compute.BackendServiceBackendArgs{
Group: devNEG.ID(),
BalancingMode: pulumi.String("UTILIZATION"),
CapacityScaler: pulumi.Float64(1.0),
},
},
Iap: &compute.BackendServiceIapArgs{
Enabled: pulumi.Bool(true),
Oauth2ClientId: pulumi.String(oauthClientID),
Oauth2ClientSecret: oauthClientSecret,
},
}, pulumi.DependsOn([]pulumi.Resource{devNEG}))
if err != nil {
return err
}
_, err = iap.NewWebBackendServiceIamBinding(ctx, "dev-iap-access", &iap.WebBackendServiceIamBindingArgs{
Project: pulumi.String(projectID),
WebBackendService: devBackend.Name,
Role: pulumi.String("roles/iap.httpsResourceAccessor"),
Members: pulumi.StringArray{pulumi.Sprintf("user:%s", iapUser)},
}, pulumi.DependsOn([]pulumi.Resource{devBackend}))
if err != nil {
return err
}
// Prod Environment Backend
prodNEG, err := compute.NewRegionNetworkEndpointGroup(ctx, "prod-neg", &compute.RegionNetworkEndpointGroupArgs{
Project: pulumi.String(projectID),
Region: pulumi.String("asia-northeast1"),
Name: pulumi.String("koborin-ai-prod-neg"),
NetworkEndpointType: pulumi.String("SERVERLESS"),
CloudRun: &compute.RegionNetworkEndpointGroupCloudRunArgs{
Service: pulumi.String("koborin-ai-web-prod"),
},
}, pulumi.DependsOn(apiServices))
if err != nil {
return err
}
prodBackend, err := compute.NewBackendService(ctx, "prod-backend", &compute.BackendServiceArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-prod-backend"),
Protocol: pulumi.String("HTTP"),
LoadBalancingScheme: pulumi.String("EXTERNAL_MANAGED"),
TimeoutSec: pulumi.Int(30),
Backends: compute.BackendServiceBackendArray{
&compute.BackendServiceBackendArgs{
Group: prodNEG.ID(),
BalancingMode: pulumi.String("UTILIZATION"),
CapacityScaler: pulumi.Float64(1.0),
},
},
LogConfig: &compute.BackendServiceLogConfigArgs{
Enable: pulumi.Bool(true),
SampleRate: pulumi.Float64(1.0),
},
}, pulumi.DependsOn([]pulumi.Resource{prodNEG}))
if err != nil {
return err
}
// HTTPS Load Balancer
sslCert, err := compute.NewManagedSslCertificate(ctx, "managed-cert", &compute.ManagedSslCertificateArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-cert"),
Managed: &compute.ManagedSslCertificateManagedArgs{
Domains: pulumi.StringArray{
pulumi.String("koborin.ai"),
pulumi.String("dev.koborin.ai"),
},
},
}, pulumi.DependsOn(apiServices))
if err != nil {
return err
}
urlMap, err := compute.NewURLMap(ctx, "url-map", &compute.URLMapArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-url-map"),
Description: pulumi.String("Routes traffic to dev/prod backends based on host header"),
DefaultService: prodBackend.ID(),
HostRules: compute.URLMapHostRuleArray{
&compute.URLMapHostRuleArgs{
Hosts: pulumi.StringArray{pulumi.String("koborin.ai")},
PathMatcher: pulumi.String("prod-matcher"),
},
&compute.URLMapHostRuleArgs{
Hosts: pulumi.StringArray{pulumi.String("dev.koborin.ai")},
PathMatcher: pulumi.String("dev-matcher"),
},
},
PathMatchers: compute.URLMapPathMatcherArray{
&compute.URLMapPathMatcherArgs{
Name: pulumi.String("prod-matcher"),
DefaultService: prodBackend.ID(),
},
&compute.URLMapPathMatcherArgs{
Name: pulumi.String("dev-matcher"),
DefaultService: devBackend.ID(),
},
},
}, pulumi.DependsOn([]pulumi.Resource{devBackend, prodBackend}))
if err != nil {
return err
}
httpsProxy, err := compute.NewTargetHttpsProxy(ctx, "https-proxy", &compute.TargetHttpsProxyArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-https-proxy"),
UrlMap: urlMap.ID(),
SslCertificates: pulumi.StringArray{
sslCert.ID(),
},
}, pulumi.DependsOn([]pulumi.Resource{urlMap, sslCert}))
if err != nil {
return err
}
_, err = compute.NewGlobalForwardingRule(ctx, "forwarding-rule", &compute.GlobalForwardingRuleArgs{
Project: pulumi.String(projectID),
Name: pulumi.String("koborin-ai-forwarding-rule"),
Target: httpsProxy.ID(),
PortRange: pulumi.String("443"),
IpProtocol: pulumi.String("TCP"),
LoadBalancingScheme: pulumi.String("EXTERNAL_MANAGED"),
NetworkTier: pulumi.String("PREMIUM"),
IpAddress: staticIP.Address,
}, pulumi.DependsOn([]pulumi.Resource{httpsProxy, staticIP}))
if err != nil {
return err
}
// Workload Identity (for GitHub Actions)
workloadIdentityPool, err := iam.NewWorkloadIdentityPool(ctx, "github-actions-pool", &iam.WorkloadIdentityPoolArgs{
Project: pulumi.String(projectID),
WorkloadIdentityPoolId: pulumi.String("github-actions-pool"),
DisplayName: pulumi.String("github-actions-pool"),
Description: pulumi.String("Workload Identity Pool for GitHub Actions workflows"),
})
if err != nil {
return err
}
_, err = iam.NewWorkloadIdentityPoolProvider(ctx, "github-provider", &iam.WorkloadIdentityPoolProviderArgs{
Project: pulumi.String(projectID),
WorkloadIdentityPoolId: workloadIdentityPool.WorkloadIdentityPoolId,
WorkloadIdentityPoolProviderId: pulumi.String("actions-firebase-provider"),
DisplayName: pulumi.String("github-actions-provider"),
Description: pulumi.String("GitHub Actions OIDC provider"),
AttributeCondition: pulumi.String(`assertion.repository_owner == "koborin-ai"`),
AttributeMapping: pulumi.StringMap{
"google.subject": pulumi.String("assertion.repository"),
"attribute.repository_owner": pulumi.String("assertion.repository_owner"),
},
Oidc: &iam.WorkloadIdentityPoolProviderOidcArgs{
IssuerUri: pulumi.String("https://token.actions.githubusercontent.com"),
},
})
if err != nil {
return err
}
githubActionsSA, err := serviceaccount.NewAccount(ctx, "github-actions-sa", &serviceaccount.AccountArgs{
Project: pulumi.String(projectID),
AccountId: pulumi.String("github-actions-service"),
DisplayName: pulumi.String("github-actions-service"),
Description: pulumi.String("Service account for GitHub Actions to deploy via Pulumi"),
})
if err != nil {
return err
}
_, err = serviceaccount.NewIAMMember(ctx, "github-wif-user", &serviceaccount.IAMMemberArgs{
ServiceAccountId: githubActionsSA.Name,
Role: pulumi.String("roles/iam.workloadIdentityUser"),
Member: pulumi.Sprintf(
"principal://iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/subject/koborin-ai/site",
projectNumber,
workloadIdentityPool.WorkloadIdentityPoolId,
),
})
if err != nil {
return err
}
deployerRoles := []string{
"roles/artifactregistry.admin",
"roles/cloudbuild.builds.builder",
"roles/cloudbuild.builds.viewer",
"roles/run.admin",
"roles/compute.admin",
"roles/iap.admin",
"roles/logging.admin",
"roles/logging.viewer",
"roles/monitoring.admin",
"roles/resourcemanager.projectIamAdmin",
"roles/iam.serviceAccountUser",
"roles/iam.serviceAccountAdmin",
"roles/iam.workloadIdentityPoolAdmin",
"roles/serviceusage.serviceUsageAdmin",
"roles/storage.objectAdmin",
}
for _, role := range deployerRoles {
logicalName := "deployer-sa-" + strings.ReplaceAll(strings.ReplaceAll(role, ".", "-"), "/", "-")
_, err = projects.NewIAMMember(ctx, logicalName, &projects.IAMMemberArgs{
Project: pulumi.String(projectID),
Role: pulumi.String(role),
Member: pulumi.Sprintf("serviceAccount:%s", githubActionsSA.Email),
})
if err != nil {
return err
}
}
return nil
}
```
#### infra/stacks/dev.go
Defines the dev environment Cloud Run service:
- **Cloud Run Service**: `koborin-ai-web-dev`
- **IAM**: Grants `roles/run.invoker` to IAP Service Agent
View `dev.go` code
```go
package stacks
import (
"fmt"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
func Dev(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
gcpCfg := config.New(ctx, "gcp")
projectID := gcpCfg.Require("project")
projectNumber := cfg.Require("projectNumber")
imageURI := cfg.Require("imageUri")
webDev, err := cloudrunv2.NewService(ctx, "web-dev", &cloudrunv2.ServiceArgs{
Project: pulumi.String(projectID),
Location: pulumi.String("asia-northeast1"),
Name: pulumi.String("koborin-ai-web-dev"),
Ingress: pulumi.String("INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"),
Template: &cloudrunv2.ServiceTemplateArgs{
ExecutionEnvironment: pulumi.String("EXECUTION_ENVIRONMENT_GEN2"),
Containers: cloudrunv2.ServiceTemplateContainerArray{
&cloudrunv2.ServiceTemplateContainerArgs{
Image: pulumi.String(imageURI),
Envs: cloudrunv2.ServiceTemplateContainerEnvArray{
&cloudrunv2.ServiceTemplateContainerEnvArgs{
Name: pulumi.String("NODE_ENV"),
Value: pulumi.String("development"),
},
&cloudrunv2.ServiceTemplateContainerEnvArgs{
Name: pulumi.String("NEXT_PUBLIC_ENV"),
Value: pulumi.String("dev"),
},
},
},
},
Scaling: &cloudrunv2.ServiceTemplateScalingArgs{
MinInstanceCount: pulumi.Int(0),
MaxInstanceCount: pulumi.Int(1),
},
},
Traffics: cloudrunv2.ServiceTrafficArray{
&cloudrunv2.ServiceTrafficArgs{
Type: pulumi.String("TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"),
Percent: pulumi.Int(100),
},
},
})
if err != nil {
return err
}
_, err = cloudrunv2.NewServiceIamMember(ctx, "web-dev-iap-invoker", &cloudrunv2.ServiceIamMemberArgs{
Project: pulumi.String(projectID),
Location: pulumi.String("asia-northeast1"),
Name: webDev.Name,
Role: pulumi.String("roles/run.invoker"),
Member: pulumi.String(fmt.Sprintf("serviceAccount:service-%s@gcp-sa-iap.iam.gserviceaccount.com", projectNumber)),
})
if err != nil {
return err
}
return nil
}
```
#### infra/stacks/prod.go
Defines the prod environment Cloud Run service:
- **Cloud Run Service**: `koborin-ai-web-prod`
- **IAM**: Grants `roles/run.invoker` to `allUsers` (public access)
View `prod.go` code
```go
package stacks
import (
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/cloudrunv2"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
func Prod(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
gcpCfg := config.New(ctx, "gcp")
projectID := gcpCfg.Require("project")
imageURI := cfg.Require("imageUri")
webProd, err := cloudrunv2.NewService(ctx, "web-prod", &cloudrunv2.ServiceArgs{
Project: pulumi.String(projectID),
Location: pulumi.String("asia-northeast1"),
Name: pulumi.String("koborin-ai-web-prod"),
Ingress: pulumi.String("INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"),
Template: &cloudrunv2.ServiceTemplateArgs{
ExecutionEnvironment: pulumi.String("EXECUTION_ENVIRONMENT_GEN2"),
Containers: cloudrunv2.ServiceTemplateContainerArray{
&cloudrunv2.ServiceTemplateContainerArgs{
Image: pulumi.String(imageURI),
Envs: cloudrunv2.ServiceTemplateContainerEnvArray{
&cloudrunv2.ServiceTemplateContainerEnvArgs{
Name: pulumi.String("NODE_ENV"),
Value: pulumi.String("production"),
},
&cloudrunv2.ServiceTemplateContainerEnvArgs{
Name: pulumi.String("NEXT_PUBLIC_ENV"),
Value: pulumi.String("prod"),
},
},
},
},
Scaling: &cloudrunv2.ServiceTemplateScalingArgs{
MinInstanceCount: pulumi.Int(0),
MaxInstanceCount: pulumi.Int(10),
},
},
Traffics: cloudrunv2.ServiceTrafficArray{
&cloudrunv2.ServiceTrafficArgs{
Type: pulumi.String("TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"),
Percent: pulumi.Int(100),
},
},
})
if err != nil {
return err
}
_, err = cloudrunv2.NewServiceIamMember(ctx, "web-prod-invoker", &cloudrunv2.ServiceIamMemberArgs{
Project: pulumi.String(projectID),
Location: pulumi.String("asia-northeast1"),
Name: webProd.Name,
Role: pulumi.String("roles/run.invoker"),
Member: pulumi.String("allUsers"),
})
if err != nil {
return err
}
return nil
}
```
### App Implementation
#### app/astro.config.mjs
Configuration file for Astro and Starlight:
- **Multilingual support**: `root` (English) and `ja` (Japanese)
- **Sidebar**: Imported from `app/src/sidebar.ts`
- **Custom components**: Head, ThemeSelect, Header, Sidebar, Pagination
View `astro.config.mjs` code
```javascript
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import rehypeMermaid from "rehype-mermaid";
import { sidebar } from "./src/sidebar.ts";
export default defineConfig({
site: "https://koborin.ai",
srcDir: "src",
markdown: {
rehypePlugins: [
[
rehypeMermaid,
{
strategy: "inline-svg",
mermaidConfig: { theme: "neutral" },
},
],
],
},
integrations: [
starlight({
title: "koborin.ai",
defaultLocale: "root",
locales: {
root: { label: "English", lang: "en" },
ja: { label: "日本語" },
},
description: "Personal site + technical garden",
favicon: "/favicon.png",
logo: {
src: "./src/assets/_shared/koborin-ai-header.webp",
replacesTitle: true,
},
social: [
{ label: "GitHub", icon: "github", href: "https://github.com/nozomi-koborinai" },
{ label: "LinkedIn", icon: "linkedin", href: "https://linkedin.com/in/nozomi-koborinai" },
{ label: "X", icon: "x.com", href: "https://x.com/fender_kn" },
{ label: "Medium", icon: "document", href: "https://medium.com/@nozomi-koborinai" },
],
sidebar,
customCss: ['./src/styles/custom.css'],
components: {
Head: './src/components/Head.astro',
ThemeSelect: './src/components/ThemeSelect.astro',
Header: './src/components/SiteHeader.astro',
Sidebar: './src/components/Sidebar.astro',
Pagination: './src/components/Pagination.astro',
},
head: [
{ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } },
],
disable404Route: true,
}),
],
});
```
#### app/Dockerfile
Multi-stage build that serves the static site via nginx:
1. **Build stage**: Node.js for OG image optimization → `astro build`
2. **Runtime stage**: Serve static files with `nginx:alpine`
View `Dockerfile` code
```dockerfile
# Build stage
FROM node:22-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 \
libcairo2 webp imagemagick file \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install
RUN npx playwright install chromium
COPY . .
RUN ./scripts/optimize-og-images.sh
RUN npm run build
# Runtime stage
FROM nginx:alpine AS runtime
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
```
#### app/nginx/nginx.conf
nginx configuration:
- **Port 8080**: To meet Cloud Run requirements
- **UTF-8 charset**: To prevent character encoding issues in `llms.txt`
- **Cache settings**: `/_astro/` for 1 year, OG images for 7 days
- **WebP auto-serving**: Resolves requests to `/og/*.png` to `.webp`
View `nginx.conf` code
```nginx
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
include /etc/nginx/mime.types;
charset utf-8;
charset_types text/plain text/css application/json;
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
error_page 404 /404.html;
location = /404.html { root /usr/share/nginx/html; internal; }
location /_astro/ { expires 1y; add_header Cache-Control "public, immutable"; }
location ~ ^/og/(.+)\.(png|jpe?g)$ {
set $webp_file /og/$1.webp;
try_files $webp_file $uri =404;
expires 7d;
add_header Cache-Control "public";
}
location /og/ { expires 7d; add_header Cache-Control "public"; }
location = /favicon.png { expires 30d; add_header Cache-Control "public"; }
location / { try_files $uri $uri/index.html =404; }
}
}
```
---
## Conclusion
- **Simple setup**: A single GCP project works well for a personal site
- **Developer experience**: dev/prod separation with IAP protecting the dev environment
- **Frontend**: No API/DB needed, GitHub as CMS, built with Starlight
- **CDKTF → Pulumi Go migration**: Avoiding sunset risk while unifying on a preferred language
- **Behavior / Structure awareness**: Adjusting CI scope based on the nature of changes
- **Site features**: llms.txt and Engagement Features (Giscus, Share buttons) to connect with readers
## Source Code
---
## Understanding Model Context Protocol Through Building the Genkit for Dart MCP Plugin
URL: https://koborin.ai/tech/mcp-deep-dive/
import RichLinkCard from '../../../components/RichLinkCard.astro';
import SiteImage from '../../../components/SiteImage.astro';
import mcpDeepDiveOg from '../../../assets/og/mcp-deep-dive.png';
## Introduction
[Genkit](https://genkit.dev/) is an AI application development framework built by Google.
TypeScript and Go are GA, and the [Dart version](https://github.com/genkit-ai/genkit-dart) is under development as a Preview as of February 2026.
I contribute to Genkit for Dart, and as part of the effort to build a full SDK, I was responsible for implementing the MCP (Model Context Protocol) plugin.
I had already published MCP server open-source projects like [gcp-cost-mcp-server](https://github.com/nozomi-koborinai/gcp-cost-mcp-server) and [ableton-osc-mcp](https://github.com/nozomi-koborinai/ableton-osc-mcp) using the [Genkit for Go MCP package](https://pkg.go.dev/github.com/firebase/genkit/go/plugins/mcp), so I was familiar with MCP as a user.
However, Genkit for Dart also needed MCP support on par with TypeScript/Go, and this time I had to engage with MCP as someone embedding the protocol itself.
In this article, I share what I learned about the Model Context Protocol specification while implementing the MCP plugin.
This is aimed at engineers who use MCP servers daily but are not familiar with how the protocol works under the hood.
## MCP Protocol Versions
The first thing I had to deal with when implementing the MCP plugin was deciding which version of the [specification](https://modelcontextprotocol.io/specification) to target.
MCP specifications are versioned using `YYYY-MM-DD` format dates, not semver. Available features differ by version, and updates have been released every few months since the initial version in November 2024.
| Version | Key changes |
| --- | --- |
| [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05) | Initial release. JSON-RPC 2.0, Tools / Prompts / Resources, [stdio / HTTP+SSE](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) transports |
| [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26) | [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport, [OAuth 2.1](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) authorization framework ([Changelog](https://modelcontextprotocol.io/specification/2025-03-26/changelog)) |
| [2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18) | [Elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation), [Resource Links](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#resource-links), [Structured tool output](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) ([Changelog](https://modelcontextprotocol.io/specification/2025-06-18/changelog)) |
| [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) | [Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks), [OAuth Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#oauth-client-id-metadata-documents), JSON Schema 2020-12 as default ([Changelog](https://modelcontextprotocol.io/specification/2025-11-25/changelog)) |
MCP-compatible tools like Cursor, Claude Desktop, Claude Code, Windsurf, and Antigravity each implement this specification as clients.
The same applies to the server side. During connection, both parties exchange `protocolVersion` to agree on a mutually supported version (spec: [Lifecycle / Version Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation)). If the server does not support the version the client requested, it responds with a different supported version, and the client decides whether to accept it (more on this later).
For the Genkit for Dart MCP plugin, I targeted the latest [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) specification.
The [OAuth 2.1 authorization flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) was out of scope for this implementation.
## Communication Foundation of MCP: JSON-RPC 2.0
All MCP communication runs on [JSON-RPC 2.0](https://www.jsonrpc.org/specification).
JSON-RPC 2.0 is a lightweight protocol for JSON-encoded remote procedure calls.
The client and server exchange JSON messages to invoke procedures. MCP uses three types of messages:
- **Request**: Has `id` and `method` (`params` is optional). Expects a response.
- **Response**: Has `id` and either `result` or `error`. A reply to a request.
- **Notification**: Has `method` (`params` is optional) but no `id`. A one-way message that expects no response.
An MCP session always begins with an Initialize handshake.
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
C->>S: initialize (Request)
S-->>C: initialize response (Response)
C->>S: notifications/initialized
Note over C,S: Session established
C->>S: tools/list, prompts/list ...
```
The client sends an `initialize` request, the server responds, and then the client sends a `notifications/initialized` notification to establish the session.
In the Genkit for Dart MCP plugin, the client-side `_initialize()` method handles this handshake sequence.
```dart
// Client: initialize handshake (mcp_client.dart)
Future _initialize() async {
// 1. Send initialize request
final result = await _sendRequest('initialize', {
'protocolVersion': '2025-11-25',
'capabilities': _clientCapabilities(),
'clientInfo': {
'name': options.name,
'version': options.version ?? '1.0.0',
},
});
// 2. Extract information from the server's response
final serverInfo = asMap(result['serverInfo']);
if (options.serverName == null && serverInfo['name'] is String) {
_serverName = serverInfo['name'] as String;
}
// 3. For HTTP transport, set the negotiated version in headers
final negotiatedVersion = result['protocolVersion'];
if (negotiatedVersion is String &&
_transport is StreamableHttpClientTransport) {
(_transport as StreamableHttpClientTransport).setProtocolVersion(
negotiatedVersion,
);
}
// 4. Send notifications/initialized to establish the session
await _sendNotification('notifications/initialized', {});
}
```
On the server side, `handleRequest()` receives the `initialize` request and returns its own `protocolVersion`, `capabilities`, and `serverInfo`.
```dart
// Server: handling the initialize request (mcp_server.dart)
case 'initialize':
return _respond(id, {
'protocolVersion': '2025-11-25',
'capabilities': _serverCapabilities(),
'serverInfo': {
'name': options.name,
'version': options.version ?? '1.0.0',
},
});
case 'notifications/initialized':
return null; // No response needed for notifications
```
The actual JSON exchanged by this code looks like this:
**Client → Server (initialize request):**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": { "listChanged": true }
},
"clientInfo": {
"name": "my-client",
"version": "1.0.0"
}
}
}
```
**Server → Client (initialize response):**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true },
"prompts": { "listChanged": true },
"resources": { "listChanged": true, "subscribe": true },
"logging": {},
"tasks": { "cancel": {}, "list": {} }
},
"serverInfo": {
"name": "my-server",
"version": "0.1.0"
}
}
}
```
Now let's look at `protocolVersion` and `capabilities` in detail.
`protocolVersion` is the field used to agree on "the protocol version to use for this session" during the `initialize` handshake ([Lifecycle / Version Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation)).
If the server supports the requested version, it responds with the same value. If not, the server responds with a different version it can support.
If the client cannot support the returned version, it disconnects (SHOULD disconnect).
For HTTP transport, subsequent requests must include the agreed version in the `MCP-Protocol-Version` header ([Protocol Version Header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header)).
The `capabilities` field is a declaration used to align the set of features available in the session.
## Capabilities Negotiation in MCP
MCP servers and clients do not necessarily support the same set of features.
One server might only provide Tools, while another provides Resources and Prompts as well.
During the `initialize` handshake, both sides declare "what I can do" and finalize the features available for the session ([Capabilities Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation)).
### Capability List
**Server Capabilities:**
| Capability | Description |
| --- | --- |
| `tools` | Provides tools. `listChanged` enables notifications for dynamic additions/removals |
| `prompts` | Provides prompt templates |
| `resources` | Provides resources. `subscribe` enables real-time update notifications |
| `logging` | Sends log messages |
| `completions` | Provides autocompletion |
| `tasks` | Manages asynchronous tasks (added in 2025-11-25) |
**Client Capabilities:**
| Capability | Description |
| --- | --- |
| `roots` | Provides workspace root directory information |
| `sampling` | Accepts LLM invocation requests from the server |
| `elicitation` | Accepts additional information requests from the server (added in 2025-06-18) |
| `tasks` | Manages asynchronous tasks (added in 2025-11-25) |
As a result of this negotiation, if the server does not declare `tools`, the client cannot call `tools/list`. If the client does not declare `sampling`, the server cannot request LLM invocations.
Features are not discovered by "calling and seeing if it errors" but by "declaring and agreeing upfront."
### Implementation in Genkit for Dart
The `capabilities` object is not manually assembled as JSON by MCP server/client developers — the SDK builds it automatically.
In Genkit for Dart, the server statically declares all supported features, while the client dynamically builds capabilities based on configuration.
```dart
// Server: statically declare all supported features (mcp_server.dart)
Map _serverCapabilities() {
return {
'tools': {'listChanged': true},
'prompts': {'listChanged': true},
'resources': {'listChanged': true, 'subscribe': true},
'logging': {},
'completions': {},
'tasks': {
'cancel': {},
'list': {},
'requests': {
'tools': {'call': {}},
},
},
};
}
```
On the client side, capabilities are dynamically determined based on whether the developer has set callback functions (handlers).
For example, `sampling` is "a function that handles LLM invocation requests from the server."
In Genkit for Dart, this handler is defined as an optional field in the client options.
```dart
// Client option definitions (mcp_client.dart)
class McpClientOptions {
final String name;
final String? serverName;
final String? version;
final bool rawToolResponses;
final McpServerConfig mcpServer;
final McpSamplingHandler? samplingHandler; // nullable = optional
final McpElicitationHandler? elicitationHandler; // nullable = optional
// ...
}
```
If a request arrives from the server when no handler is set, a `Method not found` error is returned.
```dart
// Error handling when handler is not set (mcp_client.dart)
Future _handleSamplingRequest(Object? id, Map params) async {
final handler = options.samplingHandler;
if (handler == null) {
_sendError(id, {
'code': -32601,
'message': 'Method not found: sampling/createMessage',
});
return;
}
// If a handler is set, process the request normally
await _respondWithClientTask(id, params, handler, requestType: 'sampling/createMessage');
}
```
To prevent this error, capabilities without handlers are simply not declared.
```dart
// Client: dynamically build based on handler availability (mcp_client.dart)
Map _clientCapabilities() {
final capabilities = {
'roots': {'listChanged': true},
};
// Declare sampling if samplingHandler is set
if (options.samplingHandler != null) {
capabilities['sampling'] = {'context': {}, 'tools': {}};
}
// Declare elicitation if elicitationHandler is set
if (options.elicitationHandler != null) {
capabilities['elicitation'] = {'form': {}, 'url': {}};
}
// Declare tasks if either sampling or elicitation is available
if (options.samplingHandler != null || options.elicitationHandler != null) {
capabilities['tasks'] = {
'cancel': {},
'list': {},
'requests': {
if (options.samplingHandler != null)
'sampling': {'createMessage': {}},
if (options.elicitationHandler != null)
'elicitation': {'create': {}},
},
};
}
return capabilities;
}
```
The server reads this response to determine "I can/cannot request sampling from this client."
## The Three Primitives of MCP
In the previous section, `tools`, `prompts`, and `resources` appeared in the Server Capabilities.
The official MCP documentation calls these three [Primitives](https://modelcontextprotocol.io/docs/concepts/architecture#primitives) — the basic building blocks of features that servers provide to clients.
### Tools
Tools are functions that the LLM calls.
Many engineers are already familiar with Gemini's or Claude's Function Calling / Tool Use. MCP Tools standardize exactly this at the protocol level. They are the most commonly used of the three primitives.
The client retrieves the list of available tools via `tools/list`, and the LLM executes a selected tool via `tools/call`.
```mermaid
sequenceDiagram
participant LLM
participant C as Client
participant S as Server
C->>S: tools/list
S-->>C: Tool list (name, inputSchema, ...)
LLM->>C: Select tool + generate arguments
C->>S: tools/call (name, arguments)
S-->>C: Execution result (content)
C->>LLM: Return result
```
**tools/list response example:**
```json
{
"tools": [
{
"name": "greet",
"description": "Greets a user by name.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Name to greet" }
},
"required": ["name"]
}
}
]
}
```
Each tool has an `inputSchema` (JSON Schema), and the LLM generates arguments according to this schema.
**tools/call request and response:**
```json
// Request
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": { "name": "Dart" }
}
}
// Response
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{ "type": "text", "text": "Hello, Dart!" }
]
}
}
```
In Genkit for Dart, the server converts Genkit Tool definitions to MCP format, exposes them, and returns execution results via `tools/call`.
```dart
// Server: convert Genkit Tool definitions to MCP format and return the list (mcp_server.dart)
Future