<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Open Source - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Open Source - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 07 Oct 2026 18:31:41 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/opensource/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Support System That Automatically Routes Bugs to GitHub with Next.js and Jev ]]>
                </title>
                <description>
                    <![CDATA[ Every website gets feedback, and most of it ends up somewhere awkward. A visitor finds a broken button and emails you. Someone else leaves a comment on social media about a page that won't load on the ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-support-system-that-automatically-routes-bugs-to-github/</link>
                <guid isPermaLink="false">6abfc515257f8ade20662b79</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Andrew Baisden ]]>
                </dc:creator>
                <pubDate>Fri, 02 Oct 2026 14:52:05 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/13431986-02ba-4353-8fa7-793542e0e03f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every website gets feedback, and most of it ends up somewhere awkward. A visitor finds a broken button and emails you. Someone else leaves a comment on social media about a page that won't load on their phone. A third person fills in your contact form with a feature idea, and it sits in your inbox between a newsletter and a receipt.</p>
<p>When you finally sit down to fix things, the bug reports are scattered across three places. Half of them are missing details, and the ones that do make it into GitHub were copied there by hand, sometimes with the visitor's email address still pasted into a public issue.</p>
<p>I wanted something better for my own projects, so I built it. <strong>IssueRelay</strong> gives any React website a small support widget where visitors can ask a question, report a bug, or suggest a feature. Every report is saved to your own database first. Then an AI model called Jev classifies it, a set of plain rules in code decides where it goes, and you review it in a private dashboard.</p>
<p>When you confirm that a report really is a bug, IssueRelay creates one clean GitHub issue for it, with the visitor's private details removed. When you later close that issue on GitHub, the support ticket closes too.</p>
<p>In this tutorial, you'll learn how the whole system works, from the widget in the browser to the webhook that keeps GitHub and the dashboard in sync. You'll also see how to deploy your own copy in about 15 minutes.</p>
<p>IssueRelay is open source on GitHub at <a href="https://github.com/andrewbaisden/issuerelay">andrewbaisden/issuerelay</a>, the widget is published on npm as <a href="https://www.npmjs.com/package/@issuerelay/widget"><code>@issuerelay/widget</code></a>, and it's running in production on my portfolio website right now.</p>
<p>I won't paste the whole codebase into this article. The repository has every file, and the setup guide walks through installation step by step. Instead, I'll show you the small pieces of code that carry the important ideas, explain what each one does, and share what I learned while building, testing, and deploying it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/41588a15-244b-496e-b48e-a284a26d2526.png" alt="The IssueRelay support widget open on a website, showing the Ask a question, Report a bug, and Suggest a feature options" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-an-ai-support-system-can-help-any-website">How an AI Support System Can Help Any Website</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-what-is-jev">What Is Jev?</a></p>
</li>
<li><p><a href="#heading-the-tech-stack">The Tech Stack</a></p>
</li>
<li><p><a href="#heading-how-a-report-travels-through-the-system">How a Report Travels Through the System</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-your-own-issuerelay">How to Deploy Your Own IssueRelay</a></p>
</li>
<li><p><a href="#heading-running-it-on-a-real-website">Running It on a Real Website</a></p>
</li>
<li><p><a href="#heading-testing-it-end-to-end-and-what-i-learned">Testing It End to End (and What I Learned)</a></p>
</li>
<li><p><a href="#heading-how-it-was-built-phases-and-ai-assisted-development">How It Was Built: Phases and AI Assisted Development</a></p>
</li>
<li><p><a href="#heading-publishing-the-widget-to-npm">Publishing the Widget to npm</a></p>
</li>
<li><p><a href="#heading-what-is-next">What Is Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along and deploy your own copy, you should have:</p>
<ul>
<li><p><strong>Working knowledge of React, Next.js, and TypeScript:</strong> the platform uses the Next.js App Router, and the widget is a React component.</p>
</li>
<li><p><strong>Node.js 24 and pnpm:</strong> installed if you want to run the project locally or use the command that creates your GitHub App.</p>
</li>
<li><p><strong>A GitHub account:</strong> plus the repository for the website or app where you want to install the widget. Confirmed bugs become issues there.</p>
</li>
<li><p><strong>A Vercel account:</strong> The free Hobby plan is enough. You'll add a Neon PostgreSQL database through Vercel's marketplace, and Neon also has a free plan.</p>
</li>
<li><p><strong>A TypeSafe account:</strong> at <a href="https://typesafe.ai">typesafe.ai</a> for Jev, the AI model that triages reports. You need an API key from the <a href="https://console.typesafe.ai/keys">TypeSafe console</a> before any report can become a GitHub issue.</p>
</li>
<li><p><strong>A React website:</strong> where you can add a component. A Next.js site is the easiest place to start.</p>
</li>
<li><p><strong>Optional: a Resend account:</strong> if you want account emails such as password resets.</p>
</li>
</ul>
<p>You don't need to be an AI expert to follow along. Jev is used through a small, typed SDK, and most of the interesting work is ordinary web engineering: databases, validation, authentication, and webhooks.</p>
<h2 id="heading-how-an-ai-support-system-can-help-any-website">How an AI Support System Can Help Any Website</h2>
<p>A support system sounds like something only big companies need, but the problem it solves shows up on almost every website:</p>
<ul>
<li><p><strong>Portfolio sites</strong> get messages from recruiters, questions about projects, and reports about pages that break on a particular browser.</p>
</li>
<li><p><strong>SaaS products</strong> get bug reports mixed with billing questions and feature requests, and each one needs a different person or process.</p>
</li>
<li><p><strong>Documentation sites</strong> get "this example doesn't work" reports that are really bugs in the product.</p>
</li>
<li><p><strong>Open source projects</strong> get users who won't open a GitHub issue themselves but will happily click a button on the website.</p>
</li>
<li><p><strong>Client sites</strong> you built for someone else get feedback that the client forwards to you days later with no details.</p>
</li>
</ul>
<p>A good system gives you one place where every report arrives, keeps each report safe even when other services fail, and sorts reports so you spend your time on the ones that matter.</p>
<p>The AI part helps with the sorting, but it should never be in charge. A model can be confidently wrong, and a public GitHub issue isn't something you want to create on a guess. So IssueRelay follows one simple rule throughout: AI recommends, a human confirms, and code enforces the rules.</p>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Here's the journey of a single report through IssueRelay:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/69c55783-997b-4f80-876b-22721315e898.png" alt="Here is the journey of a single report through IssueRelay" style="display: block;" width="600" height="400" loading="lazy">

<p>A visitor opens the widget on your site and picks a topic:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/2a45c808-676c-4a10-8b55-93a0e393e262.png" alt="The widget open on a demo site with its three topics: Ask a question, Report a bug, and Suggest a feature" style="display: block;" width="600" height="400" loading="lazy">

<p>They describe the problem and can optionally leave a name and email so you can follow up:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/cdcf5b75-44da-47f5-9eeb-21583dcfae0c.png" alt="The Report a bug form in the widget with a message, a name, and an email address filled in" style="display: block;" width="600" height="400" loading="lazy">

<p>The widget sends the report to your IssueRelay platform, which saves it and replies with a support reference the visitor can quote later:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/39e72c56-5dc2-4c76-bc86-fc9d65e71bc4.png" alt="The widget confirming Message received with the support reference SUP-9" style="display: block;" width="600" height="400" loading="lazy">

<p>From there, the report becomes a ticket in your dashboard. Jev classifies it, you review it, and if it is a real bug, one click creates a GitHub issue in your repository.</p>
<p>Here's the full feature list:</p>
<ul>
<li><p><strong>An embeddable widget:</strong> Built as a React component inside a Shadow DOM, so it needs no CSS setup and never clashes with your site's styles. It works in the Next.js App Router and under a strict Content Security Policy.</p>
</li>
<li><p><strong>Durable intake:</strong> Every report is stored in PostgreSQL before anything else runs, with protection against duplicates, per project rate limits, and a list of allowed site addresses.</p>
</li>
<li><p><strong>Bounded AI triage:</strong> Jev recommends a type and severity from the message alone.</p>
</li>
<li><p><strong>A private dashboard:</strong> With filters, classification history, and human review decisions that are stored separately from the AI's output.</p>
</li>
<li><p><strong>Careful GitHub escalation:</strong> Issues are created by a GitHub App only after an owner confirms a preview, and contact details never leave IssueRelay.</p>
</li>
<li><p><strong>Two way sync:</strong> Closing or reopening the issue on GitHub updates the ticket through signed webhooks.</p>
</li>
<li><p><strong>Self hosting:</strong> A Deploy button, a first run setup page, and a settings page make it possible to run your own copy without touching the database.</p>
</li>
</ul>
<h2 id="heading-what-is-jev">What Is Jev?</h2>
<p>Jev is a model from <a href="https://typesafe.ai">TypeSafe</a> that's built for what TypeSafe calls "System One" tasks: quick, bounded judgments as opposed to long, open ended writing.</p>
<p>Instead of asking a model to write a paragraph and then trying to parse it, you give Jev some state and a set of questions, and each question has a fixed list of possible answers. Jev picks an answer for each question and returns the probability it assigned to every option.</p>
<p>That shape is exactly what support triage needs. A ticket is a bug, a question, a feature request, a billing problem, or spam. It's low, medium, high, or critical. There's no text for the model to invent, no prompt injection that can make it write an issue title, and no free text to clean up afterwards. The output is a label and a number, and your code can check both.</p>
<p>It's also cheap and fast. At the time of writing, TypeSafe lists Jev at $42 per billion input tokens, and a support message is a few dozen tokens. The IssueRelay integration sends Jev only the visitor's message and the topic they picked. It never sends names, email addresses, ticket IDs, or anything else that identifies a person.</p>
<h2 id="heading-the-tech-stack">The Tech Stack</h2>
<p>IssueRelay is built with a modern TypeScript stack, and it's the same stack I use for my own projects. If you've read <a href="https://www.freecodecamp.org/news/author/andrewbaisden/">my other articles</a>, a lot of it will look familiar:</p>
<ul>
<li><p><strong>Next.js 16 (App Router) and React 19</strong> for the platform and the dashboard</p>
</li>
<li><p><strong>Strict TypeScript</strong> everywhere, with <strong>Zod</strong> checking every input that crosses a trust boundary: public API requests, environment variables, AI output, and GitHub webhook payloads</p>
</li>
<li><p><strong>PostgreSQL with Drizzle ORM</strong> and reviewed SQL migrations</p>
</li>
<li><p><strong>Better Auth</strong> for dashboard accounts</p>
</li>
<li><p><strong>The official TypeSafe SDK</strong> for Jev, and <strong>Octokit</strong> for the GitHub App</p>
</li>
<li><p><strong>Vitest, React Testing Library, and Playwright</strong> for tests, and <strong>Biome</strong> for linting and formatting</p>
</li>
<li><p><strong>pnpm workspaces</strong> to hold everything in one monorepo</p>
</li>
<li><p><strong>Vercel, Neon, and Resend</strong> in production</p>
</li>
</ul>
<p>The monorepo is split into small packages, each with a strict job:</p>
<table>
<thead>
<tr>
<th>Package</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td><code>apps/web</code></td>
<td>The platform: the public ticket API, the dashboard, setup, and the GitHub webhook</td>
</tr>
<tr>
<td><code>packages/widget</code></td>
<td>The browser widget published to npm. It never imports server code.</td>
</tr>
<tr>
<td><code>packages/support-contracts</code></td>
<td>The request and response shapes shared by the widget and the API</td>
</tr>
<tr>
<td><code>packages/db</code></td>
<td>The Drizzle schema, migrations, and every database query</td>
</tr>
<tr>
<td><code>packages/ai</code></td>
<td>The Jev adapter, the triage service, and the routing policy</td>
</tr>
<tr>
<td><code>packages/github</code></td>
<td>The GitHub App client, issue drafts, the privacy gate, and webhook handling</td>
</tr>
<tr>
<td><code>packages/auth</code></td>
<td>Better Auth setup, sessions, and workspace membership checks</td>
</tr>
</tbody></table>
<p>The boundaries matter more than they might look like they do. React components never talk to GitHub, Jev, or the database directly. Browser code never contains a secret. The AI package can't import the database.</p>
<p>Keeping those lines strict made the system much easier to test and to reason about, and it's the reason the widget can be published to npm without dragging any server code along with it.</p>
<h2 id="heading-how-a-report-travels-through-the-system">How a Report Travels Through the System</h2>
<p>Let's follow one report from the visitor's browser all the way to a closed GitHub issue.</p>
<h3 id="heading-step-1-the-widget">Step 1: The Widget</h3>
<p>The widget is a normal React component that you install from npm:</p>
<pre><code class="language-shell">npm install @issuerelay/widget
</code></pre>
<p>Then you render it once, for example from a client component in your root layout:</p>
<pre><code class="language-typescript">"use client";

import {
  HttpSupportSubmissionClient,
  SupportWidget,
} from "@issuerelay/widget";

const submissionClient = new HttpSupportSubmissionClient({
  apiBaseUrl: "https://your-issuerelay.vercel.app",
});

export function Support() {
  return (
    &lt;SupportWidget
      projectKey="pk_your_project_key"
      submissionClient={submissionClient}
      theme="system"
      position="bottom-right"
    /&gt;
  );
}
</code></pre>
<p><code>HttpSupportSubmissionClient</code> is the part that talks to your platform. It posts each report to your IssueRelay API without cookies or credentials, and it gives every report a submission ID so that a retry after a network error doesn't create a second ticket.</p>
<p><code>SupportWidget</code> is the button and panel your visitors see. The <code>projectKey</code> tells the platform which project the report belongs to. It's public identification, not a password, so it's safe to put in your site's code. The real protection is on the server, which only accepts reports from the site addresses you list for that project.</p>
<p>The <code>"use client"</code> line is there because the submission client is created in the browser. In the Next.js App Router, you wrap the widget in your own small client component like this and render that component from your layout.</p>
<p>Under the hood, the widget renders inside a Shadow DOM with its own bundled styles, so your site doesn't need Tailwind or a CSS import, and your styles can't accidentally restyle it.</p>
<p>You don't have to write this code by hand, either. IssueRelay's project settings page shows this exact snippet with your platform address and project key already filled in.</p>
<h3 id="heading-step-2-save-first-think-later">Step 2: Save First, Think Later</h3>
<p>When the report reaches the API, the first thing IssueRelay does is save it. Not classify it, not send it anywhere, just store it in PostgreSQL inside a transaction.</p>
<p>This is the most important design decision in the whole system. AI providers have outages. GitHub has outages. If the platform called Jev before saving the report and Jev timed out, the visitor's message would be lost, and they would never know.</p>
<p>So the rule is simple: <strong>a report is accepted only after it's safely stored, and a failure in any later step can never erase it.</strong> If Jev is down, the ticket waits in the dashboard until you run triage again.</p>
<p>Before saving, the API checks a few things:</p>
<ul>
<li><p>The request body matches the shared Zod contract, so bad input is rejected with a clear error.</p>
</li>
<li><p>The project key exists, and the request's origin is one of the project's allowed site addresses.</p>
</li>
<li><p>The project is under its rate limit.</p>
</li>
<li><p>The submission ID hasn't been used before. A repeated submission returns the original ticket reference instead of creating a duplicate.</p>
</li>
</ul>
<h3 id="heading-step-3-triage-with-jev">Step 3: Triage with Jev</h3>
<p>Once a ticket is stored, the triage service asks Jev to classify it. Here's the heart of the Jev adapter, from <code>packages/ai/src/jev-classifier.ts</code> (trimmed a little for space):</p>
<pre><code class="language-typescript">const response = await this.client.systemOne({
  state: {
    message: input.message,
    category_hint: input.categoryHint ?? null,
  },
  questions: {
    ticket_type: choice(
      "What kind of support ticket is this? The visitor-selected category hint is a weak signal, not ground truth: judge from the message content.",
      {
        question: "The visitor asks how something works or what something is.",
        bug: "Something is broken, errors, or behaves incorrectly.",
        feature_request: "The visitor requests new functionality or an improvement.",
        spam: "Unsolicited advertising, scams, or irrelevant bulk content.",
        // ...account, billing, feedback, and other
      },
    ),
    severity: choice("How urgent is this ticket?", {
      low: "Minor inconvenience, cosmetic issue, or general question.",
      medium: "Broken functionality with a workaround, or a routine request.",
      high: "Major functionality unavailable, no workaround, time-sensitive.",
      critical: "Security breach, data loss, privacy exposure, or billing harm.",
    }),
  },
});
</code></pre>
<p><code>systemOne</code> is the TypeSafe SDK call for bounded questions. The <code>state</code> object is everything Jev is allowed to see: the message and the topic the visitor picked.</p>
<p>Notice what's missing. There's no name, no email, and no ticket ID, because none of them help with classification and all of them would be private data leaving your platform.</p>
<p>Each <code>choice</code> defines one question and its possible answers. The descriptions next to each label tell Jev what the label means. The ticket type question also tells Jev to treat the visitor's chosen topic as a weak hint, because people often pick "Report a bug" for a question, or "Ask a question" for something that's clearly broken.</p>
<p>What comes back isn't trusted automatically. The adapter validates the response with a Zod schema, checks that both answers are labels from the allowed lists, and uses the probability Jev gave to the chosen type label as the confidence score.</p>
<p>If that probability is missing or outside the range 0 to 1, the result is rejected, and the ticket stays in review instead of getting a made up number.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/2530f001-0850-4603-8694-5125766b421c.png" alt="A ticket in the dashboard after triage: Jev classified it as a medium severity bug with a confidence of 1.00 and recommended it for GitHub" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-4-code-makes-the-decisions">Step 4: Code Makes the Decisions</h3>
<p>Jev recommends a type and a severity. It doesn't decide where a ticket goes or whether it becomes a GitHub issue. That job belongs to plain functions in <code>packages/ai/src/policy.ts</code>:</p>
<pre><code class="language-typescript">export function routeForType(type: TicketType): TicketRoute {
  switch (type) {
    case "bug":
      return "engineering";
    case "feature_request":
      return "product";
    case "spam":
      return "ignore";
    default:
      return "support";
  }
}

export function evaluateGitHubEscalation(input: {
  type: TicketType;
  route: TicketRoute;
  confidence: number;
}): EscalationEvaluation {
  const reasons: string[] = [];
  if (input.type !== "bug") reasons.push(`type is ${input.type}, not bug`);
  if (input.route !== "engineering") reasons.push(`route is ${input.route}, not engineering`);
  if (!(input.confidence &gt;= GITHUB_ESCALATION_CONFIDENCE_THRESHOLD)) {
    reasons.push(`confidence ${input.confidence} is below ${GITHUB_ESCALATION_CONFIDENCE_THRESHOLD}`);
  }
  return { eligible: reasons.length === 0, reasons };
}
</code></pre>
<p><code>routeForType</code> maps each ticket type to a queue. Bugs go to engineering, feature requests go to product, spam is quarantined, and everything else goes to support. Because this is a normal <code>switch</code> statement, you can read it, test it, and change it without touching the AI.</p>
<p><code>evaluateGitHubEscalation</code> decides whether a ticket is even allowed to become a GitHub issue. It must be a bug, it must be in the engineering queue, and its confidence must be at least 0.9. Instead of returning a bare <code>true</code> or <code>false</code>, it collects the reasons a ticket failed, which the dashboard shows so you always know why the <strong>Create GitHub issue</strong> button is missing.</p>
<p>The 0.9 threshold lives in one configuration file with a comment that says it's an uncalibrated starting point, not a measured accuracy. I wanted that to be honest in the code: a model score of 0.99 doesn't mean the model is right 99 percent of the time.</p>
<h3 id="heading-step-5-human-review-in-the-dashboard">Step 5: Human Review in the Dashboard</h3>
<p>Every ticket lands in a private dashboard. The projects page shows how many tickets are in each state:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/f9381b89-d338-48d9-8c47-e8f872674416.png" alt="The dashboard projects page showing two projects with ticket counts for each workflow state" style="display: block;" width="600" height="400" loading="lazy">

<p>Each project has a ticket list with filters for status, route, type, severity, and reference:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/f44934f1-fdd9-4c6a-ad46-f5a32d6d056f.png" alt="The ticket list for a project, with filters and a table of tickets showing their status, route, AI type, severity, and confidence" style="display: block;" width="600" height="400" loading="lazy">

<p>Opening a ticket shows the visitor's report, the current AI classification, the full classification history, and a timeline of everything that happened. You can run triage again, resolve the ticket, or record a review decision that changes the route, the status, or the GitHub recommendation.</p>
<p>One detail I care about: human decisions are stored in their own table, with the author and a required reason. The AI's history is never rewritten. If you override Jev, you can still see exactly what Jev said and when, which is important when you want to know how well the model is really doing.</p>
<p>The dashboard is protected by Better Auth, and every read and write is scoped to a workspace. Mutations require a same origin request, and only workspace owners can publish to GitHub.</p>
<h3 id="heading-step-6-from-bug-report-to-github-issue">Step 6: From Bug Report to GitHub Issue</h3>
<p>When a ticket passes the policy, the dashboard shows a preview of the exact issue that will be created:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/6b77db48-f122-4dcf-9da5-d858f7a6eb5d.png" alt="The GitHub escalation section showing a preview of the issue title and body, with the visitor's name and email absent from the issue" style="display: block;" width="600" height="400" loading="lazy">

<p>Look closely at that screenshot. The visitor left their name and email, and both are visible in the dashboard above, but neither appears anywhere in the issue preview.</p>
<p>That's not a coincidence. Before any issue is created, the report passes through a privacy gate in <code>packages/github/src/privacy.ts</code> that looks for email addresses, phone numbers, card numbers, private keys, API tokens, JSON web tokens, and password assignments. It also checks the report against the contact details the visitor submitted, so "Hi, Sam Visitor here" can't leak a name into a public issue. If anything is found, the preview is blocked and nothing is published.</p>
<p>When you click <strong>Create GitHub issue</strong>, a few more safeguards run:</p>
<ul>
<li><p><strong>A GitHub App, not a personal token:</strong> The App is installed only on the repositories you choose, with permission to write issues and read metadata, and nothing else.</p>
</li>
<li><p><strong>Claim first, then create:</strong> The ticket is marked as <code>creating</code> in the database before GitHub is called, so two clicks can never create two issues.</p>
</li>
<li><p><strong>A hidden marker:</strong> Each issue body ends with an opaque HTML comment tied to the ticket. If a request times out and the result is unknown, IssueRelay searches the repository for that exact marker from its own App before it ever tries again. It never blindly retries an issue it might already have created.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/dc142997-48e0-4b03-8046-6ad5fe4e3320.png" alt="The ticket after escalation, showing the linked GitHub issue and the escalation events in the timeline" style="display: block;" width="600" height="400" loading="lazy">

<p>Here's a real issue that IssueRelay created on my portfolio's public repository from a visitor report. It was created by the App's bot, labelled <code>bug</code>, and contains the report and the AI's classification, but no contact details:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/ec38844a-648e-4b98-b971-45edcadcf968.png" alt="A real GitHub issue created by the IssueRelay bot on a public repository, with a summary, the report, context, and a note that contact details are never published" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-7-keeping-github-and-the-dashboard-in-sync">Step 7: Keeping GitHub and the Dashboard in Sync</h3>
<p>The last piece closes the loop. When you close the issue on GitHub, GitHub sends a webhook to IssueRelay, and the ticket moves to resolved. Reopen the issue and the ticket goes back into the queue.</p>
<p>A webhook endpoint is public by definition, so the first thing it does is prove the request really came from GitHub. This is the verification function from <code>packages/github/src/webhook-auth.ts</code>:</p>
<pre><code class="language-typescript">export function verifyWebhookSignature(input: {
  secret: string;
  rawBody: Uint8Array;
  signatureHeader: string | null;
}): boolean {
  const { secret, rawBody, signatureHeader } = input;
  if (!secret || !signatureHeader?.startsWith("sha256=")) {
    return false;
  }
  const hex = signatureHeader.slice("sha256=".length);
  if (!/^[0-9a-f]{64}$/.test(hex)) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const actual = Buffer.from(hex, "hex");
  if (expected.length !== actual.length) return false;
  return timingSafeEqual(expected, actual);
}
</code></pre>
<p>GitHub signs every delivery with a secret that only GitHub and your platform know, and sends the signature in the <code>X-Hub-Signature-256</code> header. This function computes its own HMAC SHA256 signature over the <strong>raw request bytes</strong> and compares the two.</p>
<p>Two details are easy to get wrong. First, the signature has to be computed over the exact bytes GitHub sent, before any JSON parsing, because parsing and reformatting would change the bytes. Second, the comparison uses <code>timingSafeEqual</code>, which takes the same amount of time whether the first byte or the last byte differs, so an attacker can't guess the signature one character at a time by measuring response times. The function also returns <code>false</code> for every kind of failure without saying which one, so it leaks nothing.</p>
<p>After the signature check, IssueRelay stores each delivery ID, so a repeated delivery is ignored. It updates only an issue that belongs to the matching App installation and repository, and it applies events in the order they happened on GitHub, not the order they arrived.</p>
<h2 id="heading-how-to-deploy-your-own-issuerelay">How to Deploy Your Own IssueRelay</h2>
<p>You can run your own IssueRelay on Vercel and Neon in about 15 minutes. The complete walkthrough, including troubleshooting, is in <a href="https://github.com/andrewbaisden/issuerelay/blob/main/docs/SELF_HOSTING.md">docs/SELF_HOSTING.md</a>. Here's the short version.</p>
<h3 id="heading-step-1-deploy"><strong>Step 1: Deploy</strong></h3>
<p>The recommended path is the <strong>Deploy with Vercel</strong> button in the README. It copies the repository into your GitHub account, adds a Neon database, and asks for three random secrets.</p>
<p>The first build fails on purpose because Vercel's clone screen has no Root Directory setting, so you set Root Directory to <code>apps/web</code> in the project settings and redeploy. The production build then creates every database table for you.</p>
<p>The guide also describes a <strong>Fork and Import</strong> path that makes future updates a single click, but that path hasn't been tested end to end yet.</p>
<h3 id="heading-step-2-run-the-setup-page"><strong>Step 2: Run the Setup Page</strong></h3>
<p>Open <code>/setup</code> on your new site. It only works while the database has no accounts and you enter the <code>SETUP_TOKEN</code> you created during the deploy, so nobody who finds your URL first can claim your platform. It creates your owner account and your first project, then shows your widget key and the ready to paste widget code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/6b09f257-4b29-41bd-8c6b-e95db8200504.png" alt="The first run setup page with fields for the setup token, owner account, workspace, site name, and site addresses" style="display: block;" width="600" height="400" loading="lazy">

<p>The site address field starts with <code>http://localhost:3000</code>, which is where a Next.js app runs on your computer. Add your live address too, such as <code>https://my-site.vercel.app</code> or your own domain. If you forget, the widget will politely tell visitors "We couldn't send your message," so this is the first thing to check when a report doesn't arrive.</p>
<h3 id="heading-step-3-create-the-github-app"><strong>Step 3: Create the GitHub App</strong></h3>
<p>Setting up a GitHub App by hand has a few easy mistakes in it. The worst one is forgetting to subscribe to the Issues event, which I did myself during testing. So IssueRelay includes a command that creates the App for you from a manifest:</p>
<pre><code class="language-shell">pnpm github:create-app --platform https://your-issuerelay.vercel.app
</code></pre>
<p>This opens GitHub in your browser with everything already filled in: a private App with permission to write issues and read metadata, subscribed to the Issues event, with its webhook pointing at your platform.</p>
<p>You click <strong>Create GitHub App</strong>, GitHub redirects back to a temporary local server started by the command, and the command writes the App ID, private key, and webhook secret to a file that git ignores. It never prints them in your terminal. The terminal then lists the next setup phase.</p>
<h3 id="heading-step-4-add-your-keys-and-redeploy"><strong>Step 4: Add Your Keys and Redeploy</strong></h3>
<p>Add the three GitHub App values and your <code>TYPESAFE_API_KEY</code> to your Vercel project's environment variables, then redeploy. Jev is required for GitHub issues: without it, reports still arrive in your dashboard, but none can become an issue.</p>
<h3 id="heading-step-5-connect-your-repository"><strong>Step 5: Connect Your Repository</strong></h3>
<p>Install the App on the repository of the website where the widget will live, then open your project's <strong>Settings</strong> page in the dashboard and connect it. The page asks GitHub which installation and repository ID belong to that name, so a typo can't link the wrong repository.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/d7ca9ddf-9774-4031-a67e-51f4fbec8781.png" alt="The project settings page with the widget key, the ready to paste widget code, the allowed site addresses, and the connected GitHub repository" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-6-install-the-widget"><strong>Step 6: Install the Widget</strong></h3>
<p>Install the widget on your site with the code from the settings page, and send your first report.</p>
<p>To keep your copy up to date later, pull changes from the main repository. The guide covers the one time step needed for copies made with the Deploy button, because those copies are not GitHub forks.</p>
<h2 id="heading-running-it-on-a-real-website">Running It on a Real Website</h2>
<p>A demo is one thing, but I wanted to use IssueRelay for real, so the widget now runs on my portfolio at <a href="https://andrewbaisden.com/">andrewbaisden.com</a>:</p>
<p><img src="align=%22center%22" alt="The IssueRelay widget open in the corner of the author's portfolio website, over an illustrated London street scene" width="600" height="400" loading="lazy"></p>
<p>The website design will likely change, so if you're reading this article in the future, previous builds can be found on my GitHub.</p>
<p>Installing it taught me a few things. My portfolio was still on React 18 for its tests, while the App Router was already rendering with React 19. So I upgraded it to React 19 first and made sure every existing test passed before adding the widget. The widget matches the site's light and dark themes, sits in the bottom right corner, and has its own unit test and browser test in the portfolio repository.</p>
<p>Then I tested it like a visitor would. I sent three real reports from the live site: a question, a bug, and a feature request. Jev classified all three the way I intended, with scores between 0.95 and 1.00, and the policy routed them to support, engineering, and product. The bug became issue #3 in my public portfolio repository, which is the issue shown in the screenshot earlier. I had included my name and email with that report, and neither appears in the public issue.</p>
<h2 id="heading-testing-it-end-to-end-and-what-i-learned">Testing It End to End (and What I Learned)</h2>
<p>I didn't want a project that only worked on my machine, so testing was part of every phase instead of something saved for the end.</p>
<p>The test suite has several layers:</p>
<ul>
<li><p><strong>Unit tests</strong> for the widget, the API contract, the AI policy, the privacy gate, the setup page, and more. There are 180 of them, and none need a database.</p>
</li>
<li><p><strong>Database integration tests</strong> that run against a separate PostgreSQL test database, including concurrency tests that prove two clicks can't create two GitHub issues.</p>
</li>
<li><p><strong>Browser tests with Playwright</strong> that start their own servers on separate ports, with a separate database that is recreated for every run, so a test can never touch real data. One of those servers runs against an empty database to test the first run setup page.</p>
</li>
<li><p><strong>A package check</strong> that builds the exact npm tarball and installs it into a Vite app with a strict Content Security Policy and into a Next.js app, both outside the monorepo, then submits a report in each.</p>
</li>
<li><p><strong>A live journey test</strong> with 20 checks against a real GitHub App and a throwaway repository: submit a report, triage it, preview it, create the issue, check that no private data was published, close the issue on GitHub and wait for the webhook, reopen it, and check the timeline.</p>
</li>
</ul>
<p>I ran that live journey three times: first against my local machine through a tunnel, then against production, and finally against a completely fresh copy that I deployed by following only the setup guide. All three passed 20 out of 20.</p>
<p>More interesting than the passes, though, are the problems each stage uncovered:</p>
<ul>
<li><p><strong>The Issues event is easy to forget:</strong> The first time I created a GitHub App by hand, it had no event subscriptions, so GitHub never told IssueRelay when issues closed. That mistake is why the <code>create-app</code> command exists.</p>
</li>
<li><p><strong>Visitors mention their own names:</strong> A report like "Sarah here, the page is broken" from a visitor named Sarah would have put her name in a public issue. The privacy gate now compares every report against the contact details that came with it.</p>
</li>
<li><p><strong>Zod and strict CSP don't mix in the browser:</strong> Zod 4 briefly tests whether it can use <code>new Function</code>, and sites with a strict Content Security Policy report that as a violation. I removed Zod from the widget and wrote small validation checks instead, with a test that proves they agree with the server's Zod schemas on 270 form combinations.</p>
</li>
<li><p><strong>Vercel's clone flow has no Root Directory option, and Vercel picks the framework only once:</strong> My fresh deploy failed twice: once because Vercel built the repository root, and once because the framework was still set to "Other." The repository now pins Next.js in <code>vercel.json</code>, and the guide warns about the first failure.</p>
</li>
<li><p><strong>Deploy button copies aren't forks:</strong> A plain <code>git pull</code> from the main repository refuses to merge, so the guide now has a one time command to connect a copy to the main repository.</p>
</li>
<li><p><strong>GitHub issues need Jev:</strong> I originally listed Jev as optional. A careful review of the guide showed that without it, no real report can reach the confidence threshold. The guide and the settings page now say so clearly.</p>
</li>
<li><p><strong>Log noise matters:</strong> Every database connection logged an SSL warning at error level, which made a healthy deployment look broken. The fix was to spell out the SSL mode the driver was already using, so the warning disappeared while the certificate checks stayed exactly the same.</p>
</li>
</ul>
<p>The lesson that stuck with me most: <strong>deploying from your own documentation, word for word, finds bugs that no test will.</strong> Every one of the deployment problems above was invisible to the automated tests and obvious the moment a real person followed the guide.</p>
<h2 id="heading-how-it-was-built-phases-and-ai-assisted-development">How It Was Built: Phases and AI Assisted Development</h2>
<p>IssueRelay was built in small phases, and each phase ended with a written handoff before the next one could start:</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Outcome</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>Product definition, architecture, decisions, security, and test plans</td>
</tr>
<tr>
<td>1 and 2</td>
<td>Monorepo foundation, domain model, PostgreSQL schema, and seed data</td>
</tr>
<tr>
<td>3 and 4</td>
<td>The widget, a demo site, and the public ticket API</td>
</tr>
<tr>
<td>5 and 6</td>
<td>AI triage with Jev and the operator dashboard</td>
</tr>
<tr>
<td>7 and 8</td>
<td>Confirmed GitHub escalation and signed webhook sync</td>
</tr>
<tr>
<td>9</td>
<td>Live validation of the full journey in a throwaway repository</td>
</tr>
<tr>
<td>10</td>
<td>Production hardening</td>
</tr>
<tr>
<td>11 and 12</td>
<td>Validating and publishing the widget to npm</td>
</tr>
<tr>
<td>Deploy</td>
<td>Vercel, Neon, and Resend in production</td>
</tr>
<tr>
<td>13</td>
<td>Installing the widget on my portfolio</td>
</tr>
<tr>
<td>14 and 15</td>
<td>Dogfooding (ongoing)</td>
</tr>
<tr>
<td>16</td>
<td>Self hosting: the Deploy button, the setup page, project settings, and the App manifest command</td>
</tr>
</tbody></table>
<h3 id="heading-my-developer-setup">My Developer Setup</h3>
<p>I did most of the work in the terminal. My setup is:</p>
<ul>
<li><p>Ghostty as my terminal, running Claude Code, Codex, and OpenCode</p>
</li>
<li><p>Cursor as my editor</p>
</li>
<li><p>The native desktop apps for ChatGPT, Claude, and OpenCode</p>
</li>
</ul>
<p>My main model for building IssueRelay was <strong>Claude Opus 5.5</strong> in Claude Code. For code reviews and for checking a phase before I signed it off, I used other models, including <strong>GPT-6 Sol</strong> and Grok, along with various other frontier and free models.</p>
<p>A second model reading the same code with fresh eyes caught real problems. For example, a Grok review of Phases 7 and 8 raised 15 findings. Seven were valid, including a race in claiming issue creation and issue markers that could be guessed, and all seven were fixed before I moved on. Three more were partly valid and five were deferred with written reasons.</p>
<p>Anthropic's newly released <strong>Sonnet 5.5</strong> and OpenAI's <strong>GPT-6.1 Sol</strong> weren't used in this project.</p>
<h3 id="heading-how-better-prompts-improved-the-codebase">How Better Prompts Improved the Codebase</h3>
<p>The biggest improvement in quality didn't come from a smarter model. It came from giving the model better instructions and a better structure to work in. Here is what worked:</p>
<ul>
<li><p><strong>One phase at a time:</strong> Each prompt asked for exactly one phase with a clear outcome, and the AI wasn't allowed to start the next phase until I approved it. Small, reviewable changes were much easier to check than one giant feature.</p>
</li>
<li><p><strong>A plan before any code:</strong> For bigger phases I asked for a plan first ("Create a plan and then go ahead with it once I approve it"). Reading a plan takes two minutes. Unpicking a wrong implementation takes an afternoon.</p>
</li>
<li><p><strong>Rules that live in the repository:</strong> An <code>AGENTS.md</code> file holds the project's rules, such as "persist an accepted ticket before external AI or GitHub calls," "never publish contact data to GitHub," and "do not blindly retry an ambiguous GitHub issue creation." Every AI session reads it, so the rules don't depend on me remembering to repeat them.</p>
</li>
<li><p><strong>Honest reporting:</strong> The instructions say never to report an unrun check as passing, and every handoff records the commands that were run and their real results, including failures.</p>
</li>
<li><p><strong>Clear conditions for committing:</strong> Prompts like "commit and push when tests pass and there are no other issues" meant the full test suite ran before anything reached the main branch.</p>
</li>
<li><p><strong>Asking for proof, not promises:</strong> Instead of asking "does self hosting work?", I asked the AI to verify it by following the guide on a fresh deployment. That single request uncovered seven documentation and configuration problems.</p>
</li>
<li><p><strong>Feeding back real use:</strong> When I deployed a test site myself and wrote down everything that confused me, those notes went straight back into the guide, the setup page, and the settings page.</p>
</li>
</ul>
<h2 id="heading-publishing-the-widget-to-npm">Publishing the Widget to npm</h2>
<p>The widget is the only part of IssueRelay that is published, as <a href="https://www.npmjs.com/package/@issuerelay/widget"><code>@issuerelay/widget</code></a>. Everything else stays private inside the monorepo.</p>
<p>I didn't want to publish something that only worked inside my own workspace, so the release check builds the exact tarball that npm will receive and inspects it.</p>
<p>It must contain only five files. It must not reference private packages, Node built ins, environment variables, or anything that looks like a key. It must then install and work in two brand new apps outside the repository, one of them under a strict Content Security Policy, with zero policy violations.</p>
<p>Releases are published from GitHub Actions with npm trusted publishing and provenance, so there is no long lived npm token to leak.</p>
<p>The result is a package of about 10 KB compressed that needs no CSS setup and depends only on React and React Hook Form. The <a href="https://github.com/andrewbaisden/issuerelay/tree/main/packages/widget#readme">widget README</a> documents every prop.</p>
<h2 id="heading-what-is-next">What Is Next</h2>
<p>IssueRelay is complete for self hosting, and I'm using it every day on my portfolio. Some things I would like to explore next:</p>
<ul>
<li><p><strong>A hosted version</strong> of IssueRelay, so you could sign up and add the widget without deploying anything yourself</p>
</li>
<li><p><strong>Testing the Fork and Import path</strong> end to end so it can become the recommended way to deploy</p>
</li>
<li><p>Ideas from the roadmap, such as detecting duplicate reports, linking several reports to one issue, notifications, and syncing GitHub comments</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you saw how to build an AI support system that turns scattered website feedback into reviewed tickets and routes confirmed bugs to GitHub. Along the way, you learned how to:</p>
<ul>
<li><p>Build an embeddable React widget that works on any site without CSS setup or style clashes</p>
</li>
<li><p>Save every report before calling any external service, so provider outages never lose data</p>
</li>
<li><p>Use Jev for bounded, validated classification that returns labels and probabilities instead of free text</p>
</li>
<li><p>Keep routing and publishing decisions in plain, testable code, with a human in the loop</p>
</li>
<li><p>Create GitHub issues safely with a GitHub App, a privacy gate, and a marker that prevents duplicates</p>
</li>
<li><p>Keep GitHub and your dashboard in sync with signed, verified webhooks</p>
</li>
<li><p>Deploy your own copy on Vercel and Neon, and test it end to end, including against your own documentation</p>
</li>
</ul>
<p>The best way to understand IssueRelay is to try it. You can <a href="https://github.com/andrewbaisden/issuerelay">explore the code on GitHub</a>, deploy your own copy with the <a href="https://github.com/andrewbaisden/issuerelay/blob/main/docs/SELF_HOSTING.md">self hosting guide</a>, and add the widget to your site with <code>npm install @issuerelay/widget</code> from <a href="https://www.npmjs.com/package/@issuerelay/widget">npm</a>. If it helps you, a star on the repository is always appreciated.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Code Graph in TypeScript Using VS Code's Language APIs ]]>
                </title>
                <description>
                    <![CDATA[ Modern codebases are becoming increasingly difficult to navigate. This isn't necessarily because developers are writing more code themselves. It's mostly because coding assistants are generating hundr ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-code-graph-in-typescript-using-vs-code-language-apis/</link>
                <guid isPermaLink="false">6abe3d817fd604967ed16903</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[  vscode ]]>
                    </category>
                
                    <category>
                        <![CDATA[ programming languages ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ graphs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Otobong Peter ]]>
                </dc:creator>
                <pubDate>Thu, 01 Oct 2026 11:01:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/2101c28a-d568-4841-95e6-ab066d2cf27f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Modern codebases are becoming increasingly difficult to navigate. This isn't necessarily because developers are writing more code themselves. It's mostly because coding assistants are generating hundreds or even thousands of lines of code, and the problem has become code review.</p>
<p>In the pre-LLM era, you might spend days writing a few lines of code. This meant your context on any project grew incrementally with your contribution. You only had to review and get acquainted with new code if you joined a new team or got into a new job.</p>
<p>But today, one prompt can generate thousands of lines of code across 100s of files in minutes. At that scale, the traditional format of reviewing code begins to collapse, and you spend more time reviewing code than actually writing it.</p>
<p>For instance, if you open a large TypeScript project and want to answer a seemingly simple question such as:</p>
<blockquote>
<p>"What calls this function?"</p>
</blockquote>
<p>you'll probably start by searching through files. You might use your editor's "Find References" feature. You might jump between definitions. You might search for imports, exports, and function names.</p>
<p>But there's another way to think about the problem. Instead of treating a codebase as a collection of files, we can model it as a graph.</p>
<p>Functions become nodes and calls become edges.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/219f1378-2606-4ac1-ad4f-635c0140d72d.png" alt="Visualization of method calls if they were a graph" style="display: block;" width="600" height="400" loading="lazy">

<p>Once code is represented as a graph, questions such as "what calls this function?" or "what does this function eventually call?" become graph traversal problems.</p>
<p>In this tutorial, we'll build the core of a code graph using TypeScript and VS Code's built-in language APIs. We won't write our own TypeScript parser. Instead, we'll use the semantic information VS Code and the installed language extension already provide. The result will be a graph containing files, functions, methods, and call relationships that can be displayed in a VS Code Webview.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
</ul>
</li>
<li><p><a href="#heading-1-understanding-the-vs-code-language-apis">1. Understanding the VS Code Language APIs</a></p>
</li>
<li><p><a href="#heading-2-setting-up-the-extension">2. Setting Up the Extension</a></p>
</li>
<li><p><a href="#heading-3-designing-the-graph-data-model">3. Designing the Graph Data Model</a></p>
<ul>
<li><a href="#heading-stable-symbol-ids">Stable Symbol IDs</a></li>
</ul>
</li>
<li><p><a href="#heading-4-finding-functions-and-methods">4. Finding Functions and Methods</a></p>
</li>
<li><p><a href="#heading-5-finding-the-symbol-at-a-position">5. Finding the Symbol at a Position</a></p>
</li>
<li><p><a href="#heading-6-resolving-the-call-hierarchy">6. Resolving the Call Hierarchy</a></p>
</li>
<li><p><a href="#heading-7-building-a-symbol-registry">7. Building a Symbol Registry</a></p>
</li>
<li><p><a href="#heading-8-traversing-the-graph-with-bfs">8. Traversing the Graph with BFS</a></p>
</li>
<li><p><a href="#heading-9-handling-cycles">9. Handling Cycles</a></p>
</li>
<li><p><a href="#heading-10-bounding-the-graph">10. Bounding the Graph</a></p>
</li>
<li><p><a href="#heading-11-filtering-files">11. Filtering Files</a></p>
</li>
<li><p><a href="#heading-12-why-some-edges-silently-disappear">12. Why Some Edges Silently Disappear</a></p>
<ul>
<li><p><a href="#heading-1-it-stores-plain-data-not-live-items">1. It stores plain data, not live items.</a></p>
</li>
<li><p><a href="#heading-2-it-tracks-the-age-of-each-prepared-item">2. It tracks the age of each prepared item.</a></p>
</li>
<li><p><a href="#heading-3-it-retries-suspicious-empty-results">3. It retries suspicious empty results.</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-13-connecting-the-graph-to-a-webview">13. Connecting the Graph to a Webview</a></p>
</li>
<li><p><a href="#heading-14-testing-the-graph-builder">14. Testing the Graph Builder</a></p>
</li>
<li><p><a href="#heading-15-the-limitations-of-a-code-graph">15. The Limitations of a Code Graph</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>We’ll build a small code-graph engine that uses VS Code’s Call Hierarchy API to discover relationships between functions and methods, then traverses those relationships across multiple hops. Along the way, we’ll handle concurrency, stale language-tooling references, caching, and duplicate traversal so the graph remains reliable and efficient.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before following along, you should be comfortable with:</p>
<ul>
<li><p>TypeScript and basic asynchronous programming with <code>async</code>/<code>await</code></p>
</li>
<li><p>Any VS Code compartible code, VS Code extension APIs, and <code>vscode.commands.executeCommand</code></p>
</li>
<li><p>Basic graph concepts such as nodes, edges, and Breadth-First Search (BFS)</p>
</li>
<li><p>Working with maps, arrays, and generic functions in TypeScript</p>
</li>
</ul>
<p>Let's go!</p>
<p>Suppose we have the following code:</p>
<pre><code class="language-ts">function checkout() {
  processPayment();
}

function processPayment() {
  chargeCard();
}

function chargeCard() {
  saveTransaction();
}

function saveTransaction() {
  // persist transaction
}
</code></pre>
<p>We want to transform the source code into a graph. For a real codebase, the graph may span multiple files:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/f4051ab8-d84c-488c-8469-6c3852e4f917.png" alt="Image showing what a multi-file codebase would look like when visualized as a graph" style="display: block;" width="600" height="400" loading="lazy">

<p>The implementation has two main parts. The extension host uses VS Code's language APIs to discover the graph. The Webview displays the resulting graph. The interesting part of this architecture is the graph builder.</p>
<h2 id="heading-1-understanding-the-vs-code-language-apis">1. Understanding the VS Code Language APIs</h2>
<p>VS Code already exposes several commands that extensions can use to query language intelligence. For this project, four are particularly useful:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>vscode.executeDocumentSymbolProvider</code></td>
<td>Find symbols in a document</td>
</tr>
<tr>
<td><code>vscode.prepareCallHierarchy</code></td>
<td>Resolve a position to a call hierarchy item</td>
</tr>
<tr>
<td><code>vscode.provideIncomingCalls</code></td>
<td>Find callers</td>
</tr>
<tr>
<td><code>vscode.provideOutgoingCalls</code></td>
<td>Find callees</td>
</tr>
</tbody></table>
<p>These APIs sit above the language-specific implementation. For TypeScript and JavaScript, the TypeScript language service provides the underlying information. Other languages expose similar capabilities through their own language extensions and language servers, such as gopls for Go, rust-analyser for Rust, and Pyright or Pylance for Python.</p>
<p>This is particularly important because we don't need to build a separate parser and call graph engine for every language. If a language extension provides document symbols and call hierarchy support through VS Code, the same graph-building architecture can consume that information directly.</p>
<p>An AST can tell you that a function contains a call expression. It doesn't automatically tell you which function that call refers to across imports, files, modules, classes, aliases, and other language constructs.</p>
<p>The language server already performs much of that semantic work. So instead of building another parser and symbol resolver, we can ask VS Code for the information it already knows.</p>
<h2 id="heading-2-setting-up-the-extension">2. Setting Up the Extension</h2>
<p>Our <code>package.json</code> declares a command:</p>
<pre><code class="language-json">{
  "main": "./out/extension.js",
  "engines": {
    "vscode": "^1.85.0"
  },
  "activationEvents": [],
  "contributes": {
    "commands": [
      {
        "command": "codeGraphView.open",
        "title": "Code Graph: Open Graph for Active File",
        "icon": "$(type-hierarchy)"
      }
    ],
    "menus": {
      "editor/title": [
        {
          "command": "codeGraphView.open",
          "group": "navigation",
          "when": "resourceLangId == typescript"
        }
      ]
    }
  },
  "dependencies": {
    "elkjs": "^0.9.3"
  }
}
</code></pre>
<p>The extension host and Webview run in different environments, so they're bundled separately. A simplified esbuild configuration would look like this:</p>
<pre><code class="language-js">const extensionConfig = {
  entryPoints: ['src/extension.ts'],
  bundle: true,
  outfile: 'out/extension.js',
  external: ['vscode'],
  format: 'cjs',
  platform: 'node',
};

const webviewConfig = {
  entryPoints: ['webview/main.ts'],
  bundle: true,
  outfile: 'out/webview/main.js',
  format: 'iife',
  platform: 'browser',
};
</code></pre>
<p>The extension code runs in Node, while the Webview code runs in a browser environment.</p>
<h2 id="heading-3-designing-the-graph-data-model">3. Designing the Graph Data Model</h2>
<p>Before calling the language APIs, we need to decide what our graph looks like. A useful model is:</p>
<pre><code class="language-typescript">export interface SymbolRow {
  id: string;
  name: string;
  kind: 'function' | 'method';
  line: number;
  character: number;
}

export interface FileNode {
  id: string;
  label: string;
  file: string;
  symbols: SymbolRow[];
}

export interface CallEdge {
  id: string;
  source: string;
  target: string;
}

export interface GraphData {
  rootFileId: string;
  rootSymbolId?: string;
  roots: string[];
  files: FileNode[];
  edges: CallEdge[];
  truncated: boolean;
}
</code></pre>
<p>There are two important concepts.</p>
<ol>
<li><p>A <code>FileNode</code> contains the functions or methods belonging to a file.</p>
</li>
<li><p>A <code>CallEdge</code> represents a relationship between two symbols.</p>
</li>
</ol>
<p>We keep the edge direction consistent:</p>
<pre><code class="language-text">caller → callee
</code></pre>
<p>So if <code>checkout()</code> calls <code>processPayment()</code>, the graph always contains:</p>
<pre><code class="language-text">checkout → processPayment
</code></pre>
<p>even if we discovered that relationship while asking for incoming calls.</p>
<h3 id="heading-stable-symbol-ids">Stable Symbol IDs</h3>
<p>Function names aren't unique. A project can easily contain:</p>
<pre><code class="language-typescript">// users.ts
function save() {}
</code></pre>
<p>and:</p>
<pre><code class="language-typescript">// payments.ts
function save() {}
</code></pre>
<p>We therefore need an identifier based on the symbol's location.</p>
<pre><code class="language-typescript">function idOf(
  uri: vscode.Uri,
  pos: vscode.Position
): string {
  return `${uri.toString()}#${pos.line}:${pos.character}`;
}
</code></pre>
<p>For call hierarchy items, we use their <code>selectionRange</code>:</p>
<pre><code class="language-typescript">function itemId(
  item: vscode.CallHierarchyItem
): string {
  return idOf(
    item.uri,
    item.selectionRange.start
  );
}
</code></pre>
<p>Using <code>selectionRange</code> is useful because it identifies the symbol's name rather than the entire body or declaration range. This stable ID becomes the foundation for deduplication. If the same function is discovered from several paths through the graph, we can recognise that all discoveries refer to the same node.</p>
<h2 id="heading-4-finding-functions-and-methods">4. Finding Functions and Methods</h2>
<p>The first step in building the graph is discovering the symbols in the active file. VS Code exposes document symbols through:</p>
<pre><code class="language-text">vscode.executeDocumentSymbolProvider
</code></pre>
<p>We can call it like this:</p>
<pre><code class="language-ts">/*
 * Get all symbols in a document using VS Code's
 * built-in language service instead of parsing the code ourselves. This was we can get the methods, functions, variables within a document
 */
async function getDocumentSymbols(
  uri: vscode.Uri
): Promise&lt;vscode.DocumentSymbol[]&gt; {

  /*
   * `vscode.executeDocumentSymbolProvider` delegates the analysis
   * to the language provider registered for the document's language.
   */

  const result =
    await vscode.commands.executeCommand&lt;
      vscode.DocumentSymbol[] | undefined
    &gt;(
      'vscode.executeDocumentSymbolProvider',
      uri
    );

  // Return an empty list if no symbols are found.
  return result ?? [];
}
</code></pre>
<p>The returned symbols form a hierarchy. For example:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/0f41fe8b-03eb-4999-b729-2c803364b09f.png" alt="To make the graph visual easy to interact with we need to create a hierarchy" style="display: block;" width="600" height="400" loading="lazy">

<p>We need to walk that hierarchy and collect symbols that could represent callable code.</p>
<pre><code class="language-typescript">// Check whether a symbol can be treated as a callable node.
const isCallableKind = (
  kind: vscode.SymbolKind,
  includeConstructors: boolean
) =&gt;
  kind === vscode.SymbolKind.Function ||
  kind === vscode.SymbolKind.Method ||
  (
    includeConstructors &amp;&amp;
    kind === vscode.SymbolKind.Constructor
  );
</code></pre>
<p>We can recursively inspect the symbol tree:</p>
<pre><code class="language-ts">  // Recursively collect functions, methods, and variables from a symbol tree
function collectCandidates(
  symbols: vscode.DocumentSymbol[],
  isCallable: (
    kind: vscode.SymbolKind
  ) =&gt; boolean,
  out: vscode.DocumentSymbol[] = []
) {
  for (const symbol of symbols) {
    if (
      isCallable(symbol.kind) ||
      symbol.kind === vscode.SymbolKind.Variable
    ) {
      out.push(symbol);
    } else if (
      symbol.children.length
    ) {
      collectCandidates(
        symbol.children,
        isCallable,
        out
      );
    }
  }
  return out;
}
</code></pre>
<p>Variables are worth considering because functions assigned to variables can be reported differently by language tooling. For example:</p>
<pre><code class="language-typescript">const handler = () =&gt; {
  // ...
};
</code></pre>
<p>The symbol may be reported as a variable even though it participates in the call hierarchy.</p>
<h2 id="heading-5-finding-the-symbol-at-a-position">5. Finding the Symbol at a Position</h2>
<p>When the user opens the graph for a particular method, we need to determine which symbol contains the cursor position. Because document symbols are hierarchical, we can recursively find the deepest symbol containing the position.</p>
<pre><code class="language-typescript">// Find the most specific symbol containing a given position.
function symbolAt(
  symbols: vscode.DocumentSymbol[],
  position: vscode.Position
) {
  for (const symbol of symbols) {
    if (symbol.range.contains(position)) {
      return (
        symbolAt(
          symbol.children,
          position
        ) ?? symbol
      );
    }
  }
  return undefined;
}
</code></pre>
<p>This gives us the bridge between the editor and the graph. The user selects a location in the source file. We resolve that location to a symbol. Then we resolve that symbol into a call hierarchy item.</p>
<h2 id="heading-6-resolving-the-call-hierarchy">6. Resolving the Call Hierarchy</h2>
<p>The call hierarchy API works in two stages. First:</p>
<pre><code class="language-text">position → CallHierarchyItem
</code></pre>
<p>Then:</p>
<pre><code class="language-text">CallHierarchyItem → incoming/outgoing calls
</code></pre>
<p>We can prepare the item like this:</p>
<pre><code class="language-ts">async function prepare(
  uri: vscode.Uri,
  position: vscode.Position
) {
  // VS Code's language tooling already knows how to resolve
  // symbols in a source file.
  // So we can use it to prepare a call hierarchy for the symbol at this location.
  const items =
    await vscode.commands.executeCommand&lt;
      vscode.CallHierarchyItem[] | undefined
    &gt;(
      'vscode.prepareCallHierarchy',
      uri,
      position
    );

  // The command returns an array of hierarchy items. In our case,
  // we are interested in the symbol directly under the cursor,
  // so we use the first result.
  // Optional chaining also handles the case where no symbol
  // could be resolved at the given position.
  return items?.[0];
}
</code></pre>
<p>Once we have the item, we can ask for callers:</p>
<pre><code class="language-ts">async function callers(
  item: vscode.CallHierarchyItem
) {
  // Ask VS Code for all symbols that call this item.
  const calls =
    await vscode.commands.executeCommand&lt;
      vscode.CallHierarchyIncomingCall[] | undefined
    &gt;(
      'vscode.provideIncomingCalls',
      item
    );

  // Return the calling symbols, defaulting to an empty list when none are found.
  return (
    calls ?? []
  ).map(call =&gt; call.from);
}
</code></pre>
<p>Or callees:</p>
<pre><code class="language-ts">async function callees(
  item: vscode.CallHierarchyItem
) {
  // Ask VS Code for all symbols called by this item.
  const calls =
    await vscode.commands.executeCommand&lt;
      vscode.CallHierarchyOutgoingCall[] | undefined
    &gt;(
      'vscode.provideOutgoingCalls',
      item
    );

  // Return the called symbols, defaulting to an empty list when none are found.
  return (
    calls ?? []
  ).map(call =&gt; call.to);
}
</code></pre>
<p>If we have:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/98613788-692e-497c-be44-ec016f50e851.png" alt="A code graph can often have multiple callers" style="display: block;" width="600" height="400" loading="lazy">

<p>and we ask for incoming calls to <code>processPayment</code>, the language API gives us:</p>
<pre><code class="language-text">checkout
retryPayment
</code></pre>
<p>We then normalise those results into:</p>
<pre><code class="language-text">checkout → processPayment
retryPayment → processPayment
</code></pre>
<p>The same graph structure can therefore represent both incoming and outgoing traversal.</p>
<h2 id="heading-7-building-a-symbol-registry">7. Building a Symbol Registry</h2>
<p>As we crawl the graph, the same symbol can appear repeatedly. Consider:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/fa9c8696-6a35-4b10-93cf-3e54088b2050.png" alt="To improve graph visualization deduplication via a registry helps, so that the lines are cleaner with less noise" style="display: block;" width="600" height="400" loading="lazy">

<p>We should create one node for<code>C</code>, not three. A registry provides that deduplication layer.</p>
<pre><code class="language-typescript">class Registry {
  // Keep files and their symbols separately so they can be reused across the graph.
  private readonly files =
    new Map&lt;string, FileNode&gt;();

  // Store symbols by ID for fast lookup and duplicate detection.
  private readonly rows =
    new Map&lt;string, SymbolRow&gt;();

  get size() {
    return this.rows.size;
  }

  has(id: string) {
    return this.rows.has(id);
  }

  register(
    uri: vscode.Uri,
    name: string,
    kind: vscode.SymbolKind,
    position: vscode.Position
  ): string {
    // Generate a stable ID from the file and symbol position.
    const id =
      idOf(uri, position);

    // Avoid registering the same symbol more than once.
    if (this.rows.has(id)) {
      return id;
    }

    const fileId =
      uri.toString();

    let file =
      this.files.get(fileId);

    // Create the file entry the first time we encounter it.
    if (!file) {
      file = {
        id: fileId,
        label:
          vscode.workspace
            .asRelativePath(uri),
        file: uri.fsPath,
        symbols: [],
      };

      this.files.set(
        fileId,
        file
      );
    }

    // Normalize VS Code's symbol kind into the graph's simpler representation.
    const row: SymbolRow = {
      id,
      name,
      kind:
        kind ===
        vscode.SymbolKind.Method
          ? 'method'
          : 'function',
      line: position.line,
      character:
        position.character,
    };

    // Store the symbol globally and under its containing file.
    this.rows.set(id, row);
    file.symbols.push(row);

    return id;
  }
}Now the graph builder can repeatedly register symbols without worrying about duplicates.
</code></pre>
<h2 id="heading-8-traversing-the-graph-with-bfs">8. Traversing the Graph with BFS</h2>
<p>A single call hierarchy lookup gives us one hop. A useful code graph needs multiple hops. A lookup from <code>A()</code> might tell us that it calls <code>B()</code>, but it tells us nothing about what <code>B()</code> calls next.</p>
<p>To build a useful graph, we repeatedly follow these relationships: <code>A → B → C → D</code>. Each lookup expands the graph by another level, which is why we need a traversal strategy such as BFS to explore multiple hops in an efficient way.</p>
<p>For example:</p>
<pre><code class="language-mermaid">// Assuming a graph with 4 hops
A --&gt; B --&gt; C --&gt; D --&gt; E
</code></pre>
<p>If we start from <code>A</code> and request a depth of three (3 hops), we want:</p>
<pre><code class="language-text">Depth 0: A
Depth 1: B
Depth 2: C
Depth 3: D
</code></pre>
<p>Breadth-first search (BFS) is a natural fit because the graph is explicitly organised around hop depth. The traversal maintains a frontier:</p>
<pre><code class="language-text">current frontier
      ↓
discover neighbors
      ↓
next frontier
      ↓
discover neighbors
</code></pre>
<p>A basic implementation looks like this:</p>
<pre><code class="language-typescript">const walk = async (
  start: Handle,
  direction: 'incoming' | 'outgoing',
  limit: number
) =&gt; {
  // Traverse the call graph one level at a time, starting from the given symbol.
  let frontier: Handle[] = [start];

  for (
    let depth = 0;
    depth &lt; limit &amp;&amp;
    frontier.length &gt; 0;
    depth++
  ) {
    // Resolve the next level in parallel, limiting concurrency to six lookups.
    const results =
      await mapLimit(
        frontier,
        6,
        handle =&gt;
          oneHop(
            handle,
            direction
          )
      );

    const next: Handle[] = [];

    frontier.forEach(
      (handle, index) =&gt; {
        for (
          const other
            of results[index]
        ) {
          // Register newly discovered symbols before adding their relationships.
          if (
            !registry.has(
              other.node.id
            )
          ) {
            registry.register(
              other.node.uri,
              other.node.name,
              other.node.kind,
              other.node.pos
            );
          }

          // Preserve the direction of the call relationship in the graph.
          if (
            direction === 'outgoing'
          ) {
            addEdge(
              handle.node.id,
              other.node.id
            );
          } else {
            addEdge(
              other.node.id,
              handle.node.id
            );
          }

          next.push(other);
        }
      }
    );

    // Continue the traversal from the symbols discovered at this depth.
    frontier = next;
  }
};
</code></pre>
<p>The <code>mapLimit</code> helper keeps the number of concurrent language-server requests under control:</p>
<pre><code class="language-ts">async function mapLimit&lt;T, R&gt;(
  items: T[],
  limit: number,
  fn: (item: T) =&gt; Promise&lt;R&gt;
): Promise&lt;R[]&gt; {
  // Run at most `limit` async operations at the same time.
  const results =
    new Array&lt;R&gt;(items.length);

  let next = 0;

  // Create workers that share the next available item.
  const workers =
    Array.from(
      {
        length:
          Math.min(
            limit,
            items.length
          ),
      },
      async () =&gt; {
        while (
          next &lt; items.length
        ) {
          const index = next++;

          results[index] =
            await fn(
              items[index]
            );
        }
      }
    );
  await Promise.all(workers);
  return results;
}
</code></pre>
<p>The key distinction is that BFS is just local computation, while resolving a symbol often requires asking VS Code's language tooling to do real work.</p>
<p>For each symbol we visit, Code Graph View may need to query the language service for its incoming or outgoing calls. Those lookups can involve parsing source files, resolving symbols, and communicating with the language server. As the graph grows, the number of these requests grows with it.</p>
<p>So even though the traversal itself is simple, doing those lookups sequentially can make the whole process much slower. <code>mapLimit</code> addresses this by allowing several independent language-tooling requests to run concurrently, while still putting a cap on concurrency so that we don't overwhelm the language service.</p>
<h2 id="heading-9-handling-cycles">9. Handling Cycles</h2>
<p>Real code isn't a tree. It's a graph. That means cycles are normal.</p>
<p>For example:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d522352de86e39769e972eb/302d326d-e2cf-4d1a-85b3-4e7b739d865d.png" alt="A codebase is a graph not a tree, so there would often be cyclical calls" style="display: block;" width="600" height="400" loading="lazy">

<p>A naïve recursive traversal could continue indefinitely. We therefore need to track what we've already explored. But there's a subtle detail. A simple:</p>
<pre><code class="language-ts">Set&lt;string&gt;
</code></pre>
<p>isn't always enough if the same node can be reached at different depths. Instead, we can store how much traversal depth remains when we explore a node.</p>
<pre><code class="language-typescript">const explored =
  new Map&lt;string, number&gt;();
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">// Track the deepest remaining traversal already performed for this node.
const key =
  `${direction}:${node.id}`;
if (
  (explored.get(key) ?? -1)
  &lt; remainingDepth
) {
  // Revisit only when this traversal can explore deeper than before.
  explored.set(
    key,
    remainingDepth
  );
  next.push(node);
}
</code></pre>
<p>This means that if we previously reached a node with one hop remaining, but later discover it with three hops remaining, we're allowed to explore it again. That's more precise than treating the node as "visited".</p>
<h2 id="heading-10-bounding-the-graph">10. Bounding the Graph</h2>
<p>A graph can grow extremely quickly. A highly connected function might have dozens of callers. Those callers may each have dozens of callers of their own. For that reason, the graph builder should have explicit limits.</p>
<p>For example:</p>
<pre><code class="language-typescript">const MAX_SYMBOLS = 400;
const MAX_CALLS_PER_SYMBOL = 50;
const HOP_CONCURRENCY = 6;
</code></pre>
<p>If the graph reaches a limit, we don't want to pretend that the graph is complete. Instead:</p>
<pre><code class="language-typescript">let truncated = false;
</code></pre>
<p>and:</p>
<pre><code class="language-typescript">if (
  registry.size &gt;=
  MAX_SYMBOLS
) {
  truncated = true;
  continue;
}
</code></pre>
<p>The resulting <code>GraphData</code> can then tell the UI:</p>
<pre><code class="language-text">This graph was truncated.
</code></pre>
<p>This is better than allowing an unexpectedly large codebase to make the extension appear frozen.</p>
<h2 id="heading-11-filtering-files">11. Filtering Files</h2>
<p>The language server may return relationships into files that aren't part of the application we're exploring. These could be build files or outputs that are created by dependency installations or language-specific build/runtime actions. For example, a TypeScript project can lead into:</p>
<pre><code class="language-text">node_modules
</code></pre>
<p>A Python project might lead into:</p>
<pre><code class="language-text">site-packages
</code></pre>
<p>We can filter those paths before adding them to the graph.</p>
<pre><code class="language-typescript">const DEPENDENCY_DIRS =
  /\/(node_modules|vendor|target|\.venv|venv|site-packages|__pycache__|build|obj|\.dart_tool)\//;

function isWorkspaceFile(
  uri: vscode.Uri
): boolean {
  if (
    uri.scheme !== 'file' ||
    DEPENDENCY_DIRS.test(uri.path)
  ) {
    return false;
  }
  return !!vscode.workspace
    .getWorkspaceFolder(uri);
}
</code></pre>
<p>This keeps the graph focused on the user's workspace. It also demonstrates an important distinction between language intelligence and application behaviour. The language server tells us what it can resolve. Our graph builder decides what should become part of the graph.</p>
<h2 id="heading-12-why-some-edges-silently-disappear">12. Why Some Edges Silently Disappear</h2>
<p>On a large graph, some functions that clearly call each other can end up with no connection, and nothing reports an error. The problem is that a <code>CallHierarchyItem</code> is tied to language-service state. If that state becomes stale, asking for its callers or callees can return an empty array. From the graph builder's perspective, that looks exactly like a function with no callers.</p>
<p>In the VS Code implementation, call hierarchy sessions are kept for a limited number of recent requests. Our crawler can also have several lookups in flight at once, so older items can become unusable while the graph is being traversed. The graph builder deals with this in three ways.</p>
<h3 id="heading-1-it-stores-plain-data-not-live-items">1. It stores plain data, not live items.</h3>
<p>Each function is represented by a <code>NodeRef</code> containing its ID, URI, name, kind, and position. One-hop results are also cached as <code>NodeRef</code>s. This gives us enough information to recreate a call hierarchy item when necessary.</p>
<pre><code class="language-ts">interface NodeRef {
  id: string;
  uri: vscode.Uri;
  name: string;
  kind: vscode.SymbolKind;
  pos: vscode.Position;
}
</code></pre>
<h3 id="heading-2-it-tracks-the-age-of-each-prepared-item">2. It tracks the age of each prepared item.</h3>
<p>A global <code>epoch</code> counter increases whenever a new call hierarchy item is prepared. Each handle records the epoch at which its item was created. If an item becomes sufficiently old, the crawler prepares a fresh one from the stored <code>NodeRef</code>.</p>
<h3 id="heading-3-it-retries-suspicious-empty-results">3. It retries suspicious empty results.</h3>
<p>A trimmed version of <code>oneHop</code> looks like this:</p>
<pre><code class="language-typescript">const SESSION_WINDOW = 7;

// Refresh stale language-tooling references and retry once if necessary.
for (let attempt = 0; attempt &lt; 2; attempt++) {
  const stale =
    !current.item ||
    epoch - current.epoch &gt; SESSION_WINDOW;

  if (stale) {
    // Re-resolve the symbol before using an expired CallHierarchyItem.
    const fresh = await prepareFresh(
      current.node.uri,
      current.node.pos
    );

    if (!fresh) {
      return [];
    }

    current = fresh;
  }

  const items =
    await lookup(
      current.item!,
      direction
    );

  // A stale reference may return nothing, so invalidate it and retry once.
  if (
    items.length === 0 &amp;&amp;
    attempt === 0 &amp;&amp;
    epoch - current.epoch &gt; SESSION_WINDOW
  ) {
    current = {
      node: current.node,
      epoch: -1,
    };

    continue;
  }

  // Cache the resolved relationships to avoid repeating the language-tooling lookup.
  hopCache.set(key, {
    nodes: items.map(refOf),
    at: Date.now(),
  });

  return items.map(child =&gt; ({
    node: refOf(child),
    item: child,
    epoch: current.epoch,
  }));
}
</code></pre>
<p>Here, <code>lookup</code> is shorthand for the <code>vscode.provideIncomingCalls</code> or <code>vscode.provideOutgoingCalls</code> command discussed earlier. The exact session limit is an implementation detail of VS Code rather than something the extension should depend on. The crawler therefore doesn't assume that a particular limit will always exist. The <code>SESSION_WINDOW</code> simply gives us a conservative threshold for refreshing old handles.</p>
<p>This reduces lost edges, but it can't guarantee a complete graph. Language tooling can still return incomplete information or fail to resolve certain relationships.</p>
<p>The broader lesson applies to language-service APIs beyond call hierarchy: store what you need to recreate an object from a language service, not the object itself. Treat an empty result from stale state as potentially unknown, not automatically as none.</p>
<h2 id="heading-13-connecting-the-graph-to-a-webview">13. Connecting the Graph to a Webview</h2>
<p>Once the graph has been constructed, the extension needs somewhere to display it. VS Code Webviews are a natural fit.</p>
<p>The extension host creates the panel:</p>
<pre><code class="language-ts">const panel =
  vscode.window.createWebviewPanel(
    'codeGraphView',
    'Code Graph',
    vscode.ViewColumn.Beside,
    {
      enableScripts: true,
      retainContextWhenHidden: true,
    }
  );
</code></pre>
<p>The graph is sent to the Webview as serializable data:</p>
<pre><code class="language-ts">panel.webview.postMessage({
  command: 'graphData',
  data: graphData,
});
</code></pre>
<p>The Webview can then receive it:</p>
<pre><code class="language-ts">window.addEventListener(
  'message',
  event =&gt; {
    const message =
      event.data;

    if (
      message.command !==
      'graphData'
    ) {
      return;
    }

    renderGraph(
      message.data
    );
  }
);
</code></pre>
<p>At this point, the language-server side of the problem is complete.</p>
<p>We have transformed:</p>
<pre><code class="language-text">source code
</code></pre>
<p>into:</p>
<pre><code class="language-text">symbols + relationships
</code></pre>
<p>and then into:</p>
<pre><code class="language-text">GraphData
</code></pre>
<p>The visualisation layer can now use that data to render the graph.</p>
<p>A library such as ELK can be used to calculate positions for the graph without affecting the graph-building logic.</p>
<h2 id="heading-14-testing-the-graph-builder">14. Testing the Graph Builder</h2>
<p>Testing this kind of extension can be difficult if every test requires a running VS Code instance and a real language server. A better approach is to isolate the graph-building logic from VS Code itself. The crawler only really needs a few operations:</p>
<pre><code class="language-text">prepareCallHierarchy
provideIncomingCalls
provideOutgoingCalls
</code></pre>
<p>We can create a fake implementation of those commands. For example:</p>
<pre><code class="language-typescript">const sessions = new Map();

let sessionCounter = 0;

async function executeCommand(
  command,
  ...args
) {
  // Simulate VS Code creating a session when resolving a symbol.
  if (
    command ===
    'vscode.prepareCallHierarchy'
  ) {
    const id =
      'session-' +
      ++sessionCounter;

    sessions.set(id, true);

    return [
      createFakeItem(
        args,
        id
      ),
    ];
  }

  // Simulate call lookups that depend on a still-valid session.
  if (
    command ===
      'vscode.provideIncomingCalls' ||
    command ===
      'vscode.provideOutgoingCalls'
  ) {
    const item = args[0];

    // Return nothing when the CallHierarchyItem belongs to an expired session.
    if (
      !sessions.has(
        item.sessionId
      )
    ) {
      return [];
    }

    return getFakeCalls(
      item
    );
  }
}
</code></pre>
<p>The mock can model edge cases such as expired call hierarchy state. Importantly, if the fake uses a particular session limit, that should be understood as a <strong>test model</strong>, not automatically as an official VS Code API guarantee.</p>
<p>We can then generate a deterministic graph and compare the crawler's output against a simple reference BFS.</p>
<p>For example:</p>
<pre><code class="language-mermaid">flowchart LR
    A[A] --&gt; B[B]
    A --&gt; C[C]
    B --&gt; D[D]
    C --&gt; D
    D --&gt; E[E]
</code></pre>
<p>The reference implementation knows the expected edges. The production crawler runs against the fake language service. If the two results differ, the test fails. This approach lets us test the difficult graph logic without depending entirely on the editor runtime.</p>
<h2 id="heading-15-the-limitations-of-a-code-graph">15. The Limitations of a Code Graph</h2>
<p>A language-server-based call graph is useful, but it's not a complete representation of program execution. Some relationships can be difficult or impossible for static call hierarchy analysis to resolve.</p>
<p>Examples include:</p>
<ul>
<li><p>dynamic dispatch</p>
</li>
<li><p>reflection</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>event emitters</p>
</li>
<li><p>callbacks</p>
</li>
<li><p>runtime-generated code</p>
</li>
<li><p>framework-specific behavior</p>
</li>
</ul>
<p>Consider:</p>
<pre><code class="language-ts">eventEmitter.on(
  'payment.completed',
  handlePayment
);
</code></pre>
<p>A developer may understand that this creates a runtime relationship between the event and <code>handlePayment</code>. A static call graph may not represent that relationship as a normal function call. The quality of the graph therefore depends partly on the language server and the kinds of relationships it can resolve.</p>
<p>This is why the graph should be understood as a semantic approximation, not as a perfect runtime model. There's also a language-specific dimension.</p>
<p>The graph builder itself can remain largely language-agnostic, but different language extensions may provide different levels of support for document symbols and call hierarchy.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building a code graph doesn't require writing a compiler or implementing a parser from scratch. VS Code already exposes a significant amount of semantic information through its language APIs.</p>
<p>The core process is:</p>
<pre><code class="language-mermaid">Document -&gt; Document Symbols -&gt; Call Hierarchy -&gt; Graph Nodes + Edges -&gt; BFS Traversal -&gt; GraphData -&gt; Visualization
</code></pre>
<p>The most important engineering decisions aren't about drawing the graph. They're about choosing a useful graph model, creating stable symbol identities, correctly interpreting incoming and outgoing calls, controlling traversal depth and concurrency, handling cycles, and treating language-server state as something that can change.</p>
<p>Once those pieces are in place, the visualisation becomes a separate problem. That separation is what makes the architecture useful beyond a single VS Code extension.</p>
<p>The same graph model can eventually power dependency exploration, change-impact analysis, architecture views, AI context selection, and other ways of navigating increasingly complex codebases.</p>
<p>I built a working version based on this, accessible at: <a href="https://github.com/otobongfp/code-graph-view">https://github.com/otobongfp/code-graph-view</a>.</p>
<p>I'll be looking forward to seeing all the cool things you can make out of graphs to contribute to the software engineering process.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The 7 Essential Parts of Your Online Presence ]]>
                </title>
                <description>
                    <![CDATA[ Your online presence often gives potential employers, clients, partners, and recruiters their first impression of you. It's likely what they see before they ever meet you. They search your name, read  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-7-essential-parts-of-your-online-presence/</link>
                <guid isPermaLink="false">6aa9b393e892e731e337e0e3</guid>
                
                    <category>
                        <![CDATA[ portfolio ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ social media ]]>
                    </category>
                
                    <category>
                        <![CDATA[ job search ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Tue, 15 Sep 2026 21:07:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1858aeea-9446-48d3-abeb-225f14aae371.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your online presence often gives potential employers, clients, partners, and recruiters their first impression of you. It's likely what they see before they ever meet you.</p>
<p>They search your name, read your posts, and look at your profiles. All of this happens before the first call.</p>
<p>A strong online presence doesn't mean joining every social network. It means that the places people find you tell one clear, credible story about what you do.</p>
<p>Here are the seven parts that matter most:</p>
<ul>
<li><p><a href="#heading-1-a-strong-professional-profile">1. A Strong Professional Profile</a></p>
</li>
<li><p><a href="#heading-2-a-personal-website">2. A Personal Website</a></p>
</li>
<li><p><a href="#heading-3-a-portfolio-of-real-work">3. A Portfolio of Real Work</a></p>
</li>
<li><p><a href="#heading-4-a-content-trail">4. A Content Trail</a></p>
</li>
<li><p><a href="#heading-5-one-identity-across-platforms">5. One Identity Across Platforms</a></p>
</li>
<li><p><a href="#heading-6-an-obvious-way-to-reach-you">6. An Obvious Way to Reach You</a></p>
</li>
<li><p><a href="#heading-7-upkeep-and-reputation-checks">7. Upkeep and Reputation Checks</a></p>
</li>
<li><p><a href="#heading-making-it-actually-work">Making It Actually Work</a></p>
</li>
</ul>
<h2 id="heading-1-a-strong-professional-profile">1. A Strong Professional Profile</h2>
<p>After finding your name, most people check your profile next. For many, that means <a href="https://www.linkedin.com/">LinkedIn</a>.</p>
<p>Treat it as more than a résumé. Your headline should explain your focus, not just your job title. Your summary should say what problems you solve and what work you want more of.</p>
<p>Your experience section should show outcomes. Don't write that you "managed a team." Write what the team achieved. Don't write that you "worked on a product." Write the problem you solved and what changed because of it.</p>
<p>Keep it consistent: your name, title, history, and photo should match your website. A recruiter should never wonder if they found the right person.</p>
<p><a href="https://www.linkedin.com/pulse/9-examples-great-linkedin-profiles-andrea-vahl/">Here are a few LinkedIn profiles</a> that will show you how to build a profile that stands out from the rest.</p>
<h2 id="heading-2-a-personal-website">2. A Personal Website</h2>
<p>Your website is the base of everything else. It's the one place you fully control.</p>
<p>Social profiles are useful, but they belong to platforms. Designs, algorithms, and rules change. Your own site does not.</p>
<p>More importantly, your website and your LinkedIn profile aren't just the same thing in a different wrapper.</p>
<p>LinkedIn is structured around employment history. It lists your roles in chronological order, inside a template you share with hundreds of millions of people.</p>
<p>Your website is structured around <em>you</em>. It can lead with the specific problem you solve, put your best work front and center, and give visitors a feel for how you think, none of which a profile page can do well.</p>
<p>A consultant, for example, might open their site with a one-line summary of the outcome they deliver ("I help fintech startups reduce churn in their first year"), followed by two or three case studies, and a short essay about their approach. That combination would be invisible on LinkedIn and is exactly what a website is for.</p>
<p>It doesn't need to be complex. One page is often enough. Include your name, your role, a short intro, your experience, a few work samples, and how to reach you.</p>
<p>Clarity beats polish. A visitor should know in five seconds who you are and why it matters.</p>
<p>If you don't want to code, Onepage is an AI-powered website and landing page builder that lets you turn a simple prompt into a professional website within minutes. The AI can generate the initial layout, content, images, and individual sections, while everything remains fully editable through OnePage's visual no-code editor.</p>
<p>You can refine the design and content, add forms and other elements, connect your own domain, and publish the finished site with built-in hosting, without needing a developer or managing a complicated technical setup.</p>
<p>Prefer open source? <a href="https://docs.github.com/en/pages/quickstart">GitHub Pages</a> hosts a site, blog, or résumé for free. <a href="https://jekyllrb.com/docs/">Jekyll</a> works with it out of the box. <a href="https://gohugo.io/getting-started/quick-start/">Hugo</a> is faster and gives you more control, and both have large theme libraries so you can skip the design work.</p>
<p>The goal isn't the most impressive site. It's a reliable home that represents you well.</p>
<h2 id="heading-3-a-portfolio-of-real-work">3. A Portfolio of Real Work</h2>
<p>Credentials say what you did, while a portfolio shows it.</p>
<p>This matters most for designers, developers, writers, marketers, consultants, and researchers. If your work can be shown, show it.</p>
<p>A long project list isn't a portfolio. Each piece needs context. Explain the problem, your role, what you did, and the result.</p>
<p>An engineer can walk through the technical problem, the architecture choices, and the outcome. A marketer can cover the strategy, audience, and numbers. A designer can show how research shaped the final screens.</p>
<p>The most useful projects to include are ones where you can clearly explain the problem, your specific contribution, and a measurable result.</p>
<p>A developer might show a tool they built to automate something tedious, explaining the technical tradeoffs and the time it saved. A product manager might walk through a prioritization call , why they chose one feature over three others, what data drove it, and what shipped as a result.</p>
<p>A UX researcher might document a usability study: the questions, the findings, and how the design changed because of them. These are more convincing than a list of company names because they let someone evaluate <em>how</em> you think, not just <em>where</em> you've been.</p>
<p>Is your work confidential? You can still prove skill. Use anonymized case studies, technical articles, talks, or written lessons.</p>
<p>For developers, open source work is one of the strongest portfolio signals available. A public repository shows real code, real decisions, and (if it has contributors or stars) real adoption.</p>
<p>If you've contributed to an existing project, link to specific pull requests and explain the problem you solved. If you've built your own tool or library, make sure the README reads like a case study, what does it do, why did you build it, and who is it for?</p>
<p>If you're looking for inspiration on how to present your portfolio site itself, the <a href="https://github.com/topics/portfolio-website">portfolio-website topic</a> on GitHub is a useful starting point: developers have tagged thousands of live portfolio sites there, covering a wide range of tech stacks and layouts, and you can browse them to find patterns that work or fork one as a foundation.</p>
<p>The rule is simple: show evidence, not claims.</p>
<h2 id="heading-4-a-content-trail">4. A Content Trail</h2>
<p>Your presence gets much stronger when you publish useful ideas.</p>
<p>You don't need to post daily. A few solid articles a year beat a hundred empty ones.</p>
<p>Write about what you know. Explain a hard concept. Share a project lesson. Break down a trend. Document something you built. Describe a mistake and what it taught you.</p>
<p>Over time this becomes a searchable record of your expertise.</p>
<p>It also does something a résumé can't. A résumé lists what you did, while your writing shows how you think.</p>
<p>You can publish anywhere. A hosted platform like <a href="https://substack.com/">Substack</a> needs zero setup. A blog on your own domain gives you full ownership. Developers can also write in their repo docs or on a community site.</p>
<p>Stay focused. You want your name tied to two or three areas, not twenty.</p>
<h2 id="heading-5-one-identity-across-platforms">5. One Identity Across Platforms</h2>
<p>Your presence has many pieces. They should feel like one person.</p>
<p>Use the same name everywhere. Keep your photo roughly consistent. Link your site from your profiles, and your profiles from your site.</p>
<p>Picture someone finding your website, then LinkedIn, then GitHub. Each stop should add something. None should confuse.</p>
<p>This doesn't mean posting the same thing everywhere. Each platform has a job. Your site is the hub, LinkedIn carries your history, GitHub shows your code, and a social account or blog shows your thinking.</p>
<p>Think of them as different windows into the same room.</p>
<h2 id="heading-6-an-obvious-way-to-reach-you">6. An Obvious Way to Reach You</h2>
<p>A lot of professional sites make contact hard. That's a costly mistake.</p>
<p>If someone wants to hire you, work with you, or invite you to speak, they shouldn't have to hunt. Give them one clear next step.</p>
<p>That could be an email address. It could be a contact form, a LinkedIn link, or a booking page like <a href="https://cal.com/">Cal.com</a>.</p>
<p>You don't need to share personal details. A separate work email keeps things clean.</p>
<p>What matters is that the method is visible, current, and actually checked.</p>
<h2 id="heading-7-upkeep-and-reputation-checks">7. Upkeep and Reputation Checks</h2>
<p>An online presence isn't a one-time build.</p>
<p>A stale site can hurt as much as no site. Old titles, dead links, dropped projects, and expired domains all signal neglect.</p>
<p>Every few months, set aside an hour. Check that your site loads. Update your current role and recent wins. Cut what's out of date. Then search your own name and see what shows up.</p>
<p>That last step is the most useful one. Search engines surface old profiles, forum comments, conference pages, and accounts you forgot. <a href="https://www.google.com/alerts">Google Alerts</a> can flag new mentions for you, and Google's <a href="https://myactivity.google.com/results-about-you">results about you</a> tool lets you request removal of sensitive listings.</p>
<p>You can't control everything online, but you should know what's there.</p>
<h2 id="heading-making-it-actually-work">Making It Actually Work</h2>
<p>A good online presence isn't a pile of profiles or a fancy website. It's a clear digital identity that makes your skills easy to grasp and your work easy to check.</p>
<p>Start small: build a simple personal website. Sharpen one professional profile. Add two or three real work examples. Publish a few useful pieces. Then make your contact details obvious.</p>
<p>Use a no-code tool if you want speed. Use GitHub Pages with Jekyll or Hugo if you want control. Either path works.</p>
<p>The best presence isn't the one with the most features. It's the one that answers three questions fast. Who are you? What can you do? And why should anyone trust you?</p>
<p>Answer those clearly and your presence stops being a digital résumé. It becomes an asset that brings you opportunities, even when you aren't looking.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Convert Prometheus Histograms to OTLP with the OpenTelemetry Collector ]]>
                </title>
                <description>
                    <![CDATA[ Modern applications often expose metrics at a /metrics endpoint using the Prometheus format. Among these metrics, histograms are particularly useful. They show how often values fall into different ran ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-convert-prometheus-histogramsotlp-with-the-opentelemetry-collector/</link>
                <guid isPermaLink="false">6a8c4969642222471a04f943</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #prometheus ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Purity Udeh ]]>
                </dc:creator>
                <pubDate>Sat, 22 Aug 2026 03:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3dccb28f-d024-4c7a-9ac7-353594572842.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Modern applications often expose metrics at a <code>/metrics</code> endpoint using the Prometheus format.</p>
<p>Among these metrics, histograms are particularly useful. They show how often values fall into different ranges, like HTTP request durations, database query times, or queue processing latencies.</p>
<p>Unlike simple averages, histograms show the full picture: you can see how many requests are fast, how many are slow, and where the occasional outliers occur that might be silently degrading the user experience.</p>
<p>In payment systems, for instance, a sudden spike in transactions can expose hidden bottlenecks. Most of the requests might complete quickly, but a small percentage of slow transactions can ripple through the system, impacting retries, failures, and overall throughput. Histograms help identify these issues early by showing how values are distributed and highlighting outliers that averages obscure.</p>
<p>But not all backends understand Prometheus metrics natively. Many modern observability platforms prefer <strong>OTLP (OpenTelemetry Protocol)</strong>. Forwarding Prometheus metrics without converting them can lead to incomplete or misinterpreted data. That’s why we need a pipline to scrape, transform, and export histograms into OTLP so that your observability pipeline remains consistent and actionable.</p>
<p>In this article, we'll use the OpenTelemetry Collector to scrape Prometheus histograms from application <code>/metrics</code> endpoints, map them to the OpenTelemetry Histogram data model, and export them to our observability backend using OTLP. The Collector acts as a bridge that preserves data fidelity while ensuring compatibility with your monitoring platform.</p>
<p>To make this concrete, we'll use a small FastAPI application that simulates payment transactions. It exposes two Prometheus metrics: <code>payment_transaction_duration_seconds</code> (a histogram tracking how long each transaction takes) and <code>payment_transactions_total</code> (a counter of completed transactions). You can follow along with your own instrumented application, as anything exposing Prometheus metrics at <code>/metrics</code> will work the same way. This is the metric we'll follow from the application all the way to the observability backend.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-how-to-scrape-metrics-with-prometheus-receiver"><strong>1. How to Scrape Metrics with Prometheus Receiver</strong></a></p>
</li>
<li><p><a href="#heading-2-transforming-prometheus-histograms"><strong>2. Transforming Prometheus Histograms</strong></a></p>
</li>
<li><p><a href="#heading-3-exporting-metrics-via-otlp"><strong>3. Exporting Metrics via OTLP</strong></a></p>
</li>
<li><p><a href="#heading-4-putting-the-pipeline-together"><strong>4. Putting the Pipeline Together</strong></a></p>
</li>
<li><p><a href="#heading-5-running-the-opentelemetry-collector"><strong>5. Running the OpenTelemetry Collector</strong></a></p>
<ul>
<li><p><a href="#heading-51-set-up-signoz-cloud"><strong>5.1 Set Up SigNoz Cloud</strong></a></p>
</li>
<li><p><a href="#heading-52-start-the-fastapi-application"><strong>5.2 Start the FastAPI Application</strong></a></p>
</li>
<li><p><a href="#heading-53-start-the-collector"><strong>5.3 Start the Collector</strong></a></p>
</li>
<li><p><a href="#heading-54-generate-test-transactions"><strong>5.4 Generate Test Transactions</strong></a></p>
</li>
<li><p><a href="#heading-55-confirm-backend-receipt"><strong>5.5 Confirm Backend Receipt</strong></a></p>
</li>
<li><p><a href="#heading-56-troubleshooting"><strong>5.6 Troubleshooting</strong></a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion"><strong>Conclusion</strong></a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>Docker installed</p>
</li>
<li><p>An application exposing Prometheus metrics through a <code>/metrics</code> endpoint</p>
</li>
<li><p>An OTLP-compatible observability backend</p>
</li>
<li><p>Basic knowledge of Prometheus metrics</p>
</li>
<li><p>Basic knowledge of YAML</p>
</li>
<li><p>Basic familiarity with Docker and OpenTelemetry</p>
</li>
</ul>
<p>You don't need advanced OpenTelemetry knowledge to follow this tutorial. I'll walk through the Prometheus histogram and show what happens to it as it moves through the OpenTelemetry Collector.</p>
<h2 id="heading-1-how-to-scrape-metrics-with-prometheus-receiver">1. How to Scrape Metrics with Prometheus Receiver</h2>
<p>First, we'll collect metrics from the application. The demo application exposes its Prometheus metrics at the <code>/metrics</code> endpoint, and the Prometheus receiver periodically scrapes this endpoint and ingests the metrics into the observability pipeline. You can also inspect <code>/metrics</code> directly to see the data before the Collector reads it.</p>
<p>Configuration setup example:</p>
<pre><code class="language-yaml">receivers:
  prometheus:
    config:
      scrape_configs:
        - job_name: payment-demo
          scrape_interval: 15s
          static_configs:
            - targets: ["payment-api:8080"]
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/5e891138-0408-41ee-9ae9-a68ac056aa81.png" alt="FastAPI /metrics output showing the payment transaction duration histogram in Prometheus format" style="display: block;" width="2382" height="998" loading="lazy">

<p>What's happening behind the scenes here:</p>
<p><code>scrape_interval</code> controls how often the Collector scrapes the target. Here, we're using 15 seconds; adjust it based on how frequently you need metric updates and the load your application can handle.</p>
<p>In high-throughput systems like payment platforms or real-time processing services, the scrape interval becomes particularly important. Setting it too long may cause you to miss short-lived performance issues or transient errors, while setting it too short risks overwhelming the service with scraping requests or generating excessive network traffic.</p>
<p><code>targets</code> lists the specific endpoints that expose Prometheus metrics. You can add multiple targets when you need to scrape more than one service.</p>
<p>Finally, <code>job_name</code> is an identifier that helps group metrics logically, making them easier to manage and analyze once they reach your backend.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770317251340/2853e8ee-b3d9-49c4-b931-3194eb2ea7d2.png" alt="Diagram showing an OpenTelemetry Collector discovering metrics targets, assigning targets, querying endpoints, and scraping metrics." style="display: block;" width="1204" height="816" loading="lazy">

<h2 id="heading-2-transforming-prometheus-histograms">2. Transforming Prometheus Histograms</h2>
<p>Once the metrics are scraped, the next step is transformation. Prometheus exposes histograms as multiple time series:</p>
<ul>
<li><p><code>_bucket</code> shows how many requests fall below a certain duration.</p>
</li>
<li><p><code>_sum</code> is the total of all observed durations.</p>
</li>
<li><p><code>_count</code> is the number of observations.</p>
</li>
</ul>
<p>The <code>payment_transaction_duration_seconds</code> histogram records how long each payment transaction takes. Prometheus exposes it as <code>_bucket</code>, <code>_count</code>, and <code>_sum</code> series. When the Prometheus receiver collects these metrics, it converts them into the OpenTelemetry Histogram data model, which can then be exported through OTLP while preserving the information needed to analyze transaction latency and calculate percentiles.</p>
<p>Without histograms, averages obscure latency distributions. If most requests complete in 50ms but 5% take 2+ seconds, the average of 150ms masks that serious performance issue. Histograms capture the complete picture by recording how many observations fall into each latency bucket.</p>
<p>Keeping the distribution allows you to identify changes in latency and investigate issues such as slow database queries, overloaded services, or delays from downstream dependencies.</p>
<h3 id="heading-actual-prometheus-histogram">Actual Prometheus Histogram</h3>
<p>This is how your actual <code>/metrics</code> output would look, for example:</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/8d1e4857-7d39-4cb1-99f7-5ce184adb59c.png" alt="Prometheus /metrics output showing the payment transaction duration histogram as _bucket time series with different latency boundaries" style="display: block;" width="1098" height="320" loading="lazy">

<p>And here's the actual OpenTelemetry Histogram representation:</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/ef856e3e-99d5-42af-9f39-3eb88d752505.png" alt="OpenTelemetry Collector output showing the payment_transaction_duration_seconds metric as a Histogram with bucket boundaries and counts." style="display: block;" width="1374" height="828" loading="lazy">

<p>The output above shows the same transaction-duration data represented as an OpenTelemetry Histogram. Instead of three separate Prometheus series, the Collector now has one histogram containing the count, sum, explicit bucket boundaries, and bucket counts.</p>
<h2 id="heading-3-exporting-metrics-via-otlp">3. Exporting Metrics via OTLP</h2>
<p>Once the metrics have been scraped and processed, the Collector sends them to your observability backend using OTLP. The OTLP exporter sends the processed metrics, including histograms, counters, and gauges, to the backend.</p>
<pre><code class="language-yaml">exporters:
&nbsp; otlp:
&nbsp; &nbsp; endpoint: "otlp.backend.example.com:4317"
&nbsp; &nbsp; tls:
&nbsp; &nbsp; &nbsp; insecure: false
</code></pre>
<p><code>endpoint</code> specifies the backend address for receiving OTLP metrics. This typically points to a central observability platform that aggregates metrics from multiple services across your infrastructure.</p>
<p><code>tls</code> ensures secure data transmission between the collector and your backend. Set <code>insecure: true</code> only when intentionally connecting to an endpoint that does not use TLS, such as some local development setups</p>
<h2 id="heading-4-putting-the-pipeline-together">4. Putting the Pipeline Together</h2>
<p>We've looked at each part of the pipeline individually. Now let's connect them and follow a metric from the application all the way to the backend.</p>
<pre><code class="language-yaml">service:
  pipelines:
    metrics:
      receivers: [prometheus]
      exporters: [otlp]
</code></pre>
<p>This is the complete path our <code>payment_transaction_duration_seconds</code> metric follows, from the FastAPI application to the observability backend. The diagram above shows this flow. The next section walks through actually running it and connecting to SigNoz</p>
<p><strong>Pipeline flow:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/ba4d6362-0e7a-4194-ba89-6fb3b4687489.png" alt="Pipeline flow architecture" style="display: block;" width="1199" height="1312" loading="lazy">

<p>The above architecture diagram shows a FastAPI application exposing metrics through /metrics, the OpenTelemetry Collector scraping them with the Prometheus receiver, processing the metrics, and exporting them through OTLP to an observability backend.</p>
<h2 id="heading-5-running-the-opentelemetry-collector">5. Running the OpenTelemetry Collector</h2>
<p>We'll run the complete pipeline and verify that the transaction metrics make it from the application to SigNoz. Follow these steps (which I'll walk you through in detail below):</p>
<ol>
<li><p><strong>Set up SigNoz Cloud:</strong> Configure the endpoint and ingestion key that the Collector will use.</p>
</li>
<li><p><strong>Start the FastAPI application:</strong> The application exposes the Prometheus metrics at <code>/metrics</code>.</p>
</li>
<li><p><strong>Start the OpenTelemetry Collector:</strong> The Collector begins scraping the application's <code>/metrics</code> endpoint using the Prometheus receiver.</p>
</li>
<li><p><strong>Generate test transactions:</strong> Send requests to the application to create transaction-duration measurements.</p>
</li>
<li><p><strong>Verify the Collector and backend:</strong> Check the Collector logs to confirm that the pipeline is running, then open SigNoz and verify that the <code>payment_transaction_duration_seconds</code> metric has arrived.</p>
</li>
<li><p>Troubleshooting</p>
</li>
</ol>
<h3 id="heading-51-set-up-signoz-cloud">5.1 Set Up SigNoz Cloud</h3>
<p>SigNoz provides the observability backend that will receive the metrics exported by the OpenTelemetry Collector. For this demo, we'll use SigNoz Cloud as the observability backend, so there's nothing to install locally.</p>
<p>First, sign up for a free account at <a href="http://signoz.io">signoz.io</a>. In the SigNoz Cloud dashboard, go to <strong>Settings → Ingestion Keys</strong>. The page shows your Ingestion URL, Region, and Ingestion Key.</p>
<p>Add these to a <code>.env</code> file in your project directory:</p>
<pre><code class="language-shell">SIGNOZ_INGESTION_KEY=your-real-key
SIGNOZ_OTLP_ENDPOINT=ingest.&lt;your-region&gt;.signoz.cloud:443
</code></pre>
<p>Treat the ingestion key like a password and never commit it or share it publicly.</p>
<p>Reference those variables in your Collector configuration:</p>
<pre><code class="language-shell">exporters:
  otlp:
    endpoint: "${SIGNOZ_OTLP_ENDPOINT}"
    tls:
      insecure: false
    headers:
      signoz-ingestion-key: "${SIGNOZ_INGESTION_KEY}"
</code></pre>
<p>Then add <code>.env</code> to your <code>.gitignore</code> so the key never gets committed:</p>
<pre><code class="language-plaintext">echo ".env" &gt;&gt; .gitignore
</code></pre>
<h3 id="heading-52-start-the-fastapi-application">5.2 Start the FastAPI Application.</h3>
<p>Start the application on port 8080:</p>
<pre><code class="language-shell">uvicorn app.main:app --reload --port 8080
</code></pre>
<p>Verify that the application is running:</p>
<pre><code class="language-shell">curl http://localhost:8080/
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/b27bcf94-e5da-43e6-b51f-f17fe06d90d9.png" alt="FastAPI payment demo running locally at 127.0.0.1:8080, displaying a JSON response confirming that the payment transaction demo is running" style="display: block;" width="1202" height="662" loading="lazy">

<p>Now inspect the Prometheus metrics:</p>
<pre><code class="language-shell">curl -Ls http://localhost:8080/metrics
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/10c4e1d5-d117-419d-8557-f94884f0bad8.png" alt="Browser showing the FastAPI payment demo running successfully at localhost:8080" style="display: block;" width="2312" height="964" loading="lazy">

<p>The <code>/metrics</code> endpoint exposes the application's Prometheus metrics, including the <code>payment_transaction_duration_seconds</code> histogram that the Collector will scrape.</p>
<h3 id="heading-53-start-the-collector">5.3 Start the Collector</h3>
<p>With the application running and the Collector configuration in place, start the Collector:</p>
<pre><code class="language-shell">docker compose up --build
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/3ef283ca-bea7-4fd3-82b5-9684c3b5dea9.png" alt="Docker Compose logs showing the payment API running and the Collector successfully scraping its /metrics endpoint." style="display: block;" width="1630" height="452" loading="lazy">

<p>Check the Collector logs to confirm that the pipeline is running and that metrics are being processed.</p>
<p>This builds the <code>payment-api</code> image, starts both containers on the shared <code>telemetry</code> network, and the Collector immediately begins scraping <code>/metrics</code> from the application using the Prometheus receiver, reading your SigNoz ingestion key and endpoint from <code>.env</code> automatically.</p>
<h3 id="heading-54-generate-test-transactions">5.4 Generate Test Transactions</h3>
<p>Generate transactions with different processing times:</p>
<pre><code class="language-shell">curl -X POST "http://localhost:8080/transactions?delay_ms=50"

curl -X POST "http://localhost:8080/transactions?delay_ms=250"

curl -X POST "http://localhost:8080/transactions?delay_ms=2500"
</code></pre>
<p>Generate additional requests if you want more observations in the histogram.</p>
<p>These requests create transaction-duration observations that are recorded by the <code>payment_transaction_duration_seconds</code> histogram. The Collector picks up the updated metric during its next scrape.</p>
<h3 id="heading-55-confirm-backend-receipt">5.5 Confirm Backend Receipt</h3>
<p>Open SigNoz Cloud and search for:</p>
<pre><code class="language-plaintext">payment_transaction_duration_seconds
</code></pre>
<p>The metric should be represented as a <strong>histogram</strong>, with its bucket distribution <code>_bucket</code>, <code>_sum</code>, and <code>_count</code> available to the backend rather than appearing as unrelated Prometheus series.</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/e4ea5b60-b7d4-4435-a427-d738e45ad19b.png" alt="SigNoz Metrics Explorer showing payment_transaction_duration_seconds as a histogram with associated bucket, count, and sum data after being exported through the OpenTelemetry Collector." style="display: block;" width="3212" height="1462" loading="lazy">

<h3 id="heading-56-troubleshooting">5.6 Troubleshooting</h3>
<p>If metrics aren't flowing correctly, start by checking the log:</p>
<pre><code class="language-bash">docker logs &lt;collector-container&gt;
</code></pre>
<p>Look for connection errors, authentication failures, failed scrape attempts, or configuration errors.</p>
<p>Also verify that:</p>
<ul>
<li><p>The FastAPI application is running.</p>
</li>
<li><p><code>/metrics</code> is accessible.</p>
</li>
<li><p>The Collector can reach the application.</p>
</li>
<li><p>The SigNoz endpoint and ingestion key are correct.</p>
</li>
<li><p>The <code>.env</code> variables are available to the Collector.</p>
</li>
<li><p>The receiver and exporter names match the pipeline configuration.</p>
</li>
</ul>
<p>Use the <code>--dry-run</code> flag if available to validate before deployment.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Prometheus histograms provide a useful view of transaction latency by showing how observations are distributed across different buckets rather than reducing them to a single average.</p>
<p>In this tutorial, we followed <code>payment_transaction_duration_seconds</code> from a FastAPI application's <code>/metrics</code> endpoint through the OpenTelemetry Collector and into SigNoz.</p>
<p>The Prometheus receiver mapped the <code>_bucket</code>, <code>_count</code>, and <code>_sum</code> series into the OpenTelemetry Histogram data model, preserving the distribution of transaction durations for analysis in the backend.</p>
<p>This allows the same metric to move from a Prometheus-instrumented application into an OTLP-based observability platform without manually reconstructing the histogram.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Host Odoo: Self-Hosted vs Managed Hosting ]]>
                </title>
                <description>
                    <![CDATA[ Odoo is an open-source enterprise resource planning (ERP) platform that helps businesses manage operations such as sales, customer relationship management (CRM), inventory, accounting, human resources ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-host-odoo/</link>
                <guid isPermaLink="false">6a888912ca3910a01e7c2f66</guid>
                
                    <category>
                        <![CDATA[ Odoo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ self-hosted ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Linux ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abdul Talha ]]>
                </dc:creator>
                <pubDate>Fri, 21 Aug 2026 17:21:22 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/30766bac-af3a-4c24-a544-75846002ce99.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Odoo is an open-source enterprise resource planning (ERP) platform that helps businesses manage operations such as sales, customer relationship management (CRM), inventory, accounting, human resources, and manufacturing from a single application.</p>
<p>You can deploy it in different hosting environments, which gives you the flexibility to choose a deployment model that fits your needs.</p>
<p>Choosing the right hosting option is an important part of any Odoo deployment. It affects factors such as performance, security, maintenance, scalability, and long-term operational costs.</p>
<p>Your team can either self-host Odoo on your own infrastructure or use a managed hosting provider to handle server management. Each approach offers different levels of control, flexibility, and operational responsibility.</p>
<p>In this article, you'll learn about the different ways to host Odoo, including how to set up a basic self-hosted deployment. You'll also compare self-hosted and managed hosting and explore the advantages and limitations of each approach.</p>
<p>By the end, you'll have a better understanding of which hosting model best fits your business and technical requirements.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6729b04417afd6915f5c2e3e/e1de2a5c-7186-4aff-a8e3-84bbe6eb1ea2.png" alt="e1de2a5c-7186-4aff-a8e3-84bbe6eb1ea2" style="display: block;" width="1920" height="1280" loading="lazy">

<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-are-your-odoo-hosting-options">What Are Your Odoo Hosting Options?</a></p>
</li>
<li><p><a href="#heading-self-hosted-odoo">Self-Hosted Odoo</a></p>
</li>
<li><p><a href="#heading-managed-odoo-hosting">Managed Odoo Hosting</a></p>
</li>
<li><p><a href="#heading-self-hosted-vs-managed-hosting">Self-Hosted vs Managed Hosting</a></p>
</li>
<li><p><a href="#heading-how-to-choose-the-right-option">How to Choose the Right Option</a></p>
</li>
<li><p><a href="#heading-key-factors-to-consider-before-choosing">Key Factors to Consider Before Choosing</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-are-your-odoo-hosting-options">What Are Your Odoo Hosting Options?</h2>
<p>Odoo can be hosted in different ways depending on your organization's needs. The main difference between these options is who manages the infrastructure and day-to-day maintenance. In most cases, businesses choose between two hosting models:</p>
<ol>
<li><p><strong>Self-Hosted Odoo:</strong> With a self-hosted deployment, you install and manage Odoo on infrastructure that you control, such as a virtual private server (VPS), dedicated server, cloud virtual machine, or on-premises server. This approach gives you greater control over the environment, but your team is also responsible for maintaining and securing it.</p>
</li>
<li><p><strong>Managed Odoo Hosting:</strong> With managed hosting, a hosting provider manages the infrastructure and handles routine maintenance tasks. This allows your team to focus on using Odoo for business operations instead of managing servers.</p>
</li>
</ol>
<p>The following sections examine both hosting models in more detail, including their benefits, limitations, and ideal use cases. You'll also learn how to set up a basic self-hosted Odoo deployment and what to consider before choosing a hosting option.</p>
<h2 id="heading-self-hosted-odoo">Self-Hosted Odoo</h2>
<p>Self-hosting Odoo means deploying and managing the application on infrastructure that you control, such as a virtual private server (VPS), dedicated server, cloud virtual machine, or an on-premises server.</p>
<p>With this approach, your organization is responsible for installing, configuring, maintaining, and securing both Odoo and the infrastructure.</p>
<h3 id="heading-benefits-of-self-hosting">Benefits of Self-Hosting</h3>
<p>Self-hosting gives you greater control and flexibility over your deployment. Some of the key benefits include:</p>
<ul>
<li><p>Complete control over the hosting environment.</p>
</li>
<li><p>Freedom to choose your operating system, database configuration, and hosting provider.</p>
</li>
<li><p>Support for custom modules, integrations, and server configurations.</p>
</li>
<li><p>Flexibility to optimize performance based on your workload.</p>
</li>
<li><p>Greater control over scaling and infrastructure resources.</p>
</li>
</ul>
<h3 id="heading-challenges-of-self-hosting">Challenges of Self-Hosting</h3>
<p>Along with greater control comes additional responsibility. When you self-host Odoo, you are responsible for:</p>
<ul>
<li><p>Installing software updates and security patches.</p>
</li>
<li><p>Managing backups and disaster recovery.</p>
</li>
<li><p>Monitoring server performance and application availability.</p>
</li>
<li><p>Troubleshooting infrastructure and application issues.</p>
</li>
<li><p>Securing the server against potential threats.</p>
</li>
</ul>
<p>Organizations should ensure they have the necessary technical expertise before choosing this deployment model.</p>
<h3 id="heading-who-should-choose-self-hosted-odoo">Who Should Choose Self-Hosted Odoo?</h3>
<p>Self-hosted Odoo is a good choice for:</p>
<ul>
<li><p>Developers and DevOps teams.</p>
</li>
<li><p>Organizations with in-house IT administrators.</p>
</li>
<li><p>Businesses that require extensive customization.</p>
</li>
<li><p>Teams that need complete control over their infrastructure.</p>
</li>
<li><p>Organizations with specific security or compliance requirements.</p>
</li>
</ul>
<h3 id="heading-how-to-self-host-odoo">How to Self Host Odoo</h3>
<p>The following steps show how to deploy Odoo using Docker Compose, PostgreSQL, and Traefik. Traefik acts as the reverse proxy and handles HTTPS certificates for your domain.</p>
<h4 id="heading-prerequisites">Prerequisites</h4>
<ul>
<li><p>Linux server with 2 vCPU and 4 GB RAM.</p>
</li>
<li><p>Docker and Docker Compose installed.</p>
</li>
<li><p>Domain name with an A record pointing to the server.</p>
</li>
<li><p>Inbound TCP traffic allowed on ports <strong>80</strong> and <strong>443</strong>.</p>
</li>
</ul>
<h4 id="heading-prepare-the-project-directory">Prepare the Project Directory</h4>
<p>Create a directory for the Odoo deployment:</p>
<pre><code class="language-shell">mkdir ~/odoo
</code></pre>
<p>Navigate to the project directory:</p>
<pre><code class="language-shell">cd ~/odoo
</code></pre>
<p>Create directories for persistent Odoo data, PostgreSQL data, custom addons, and Let's Encrypt certificates:</p>
<pre><code class="language-shell">mkdir -p odoo-data postgres-data addons letsencrypt
</code></pre>
<p>Set the ownership of the Odoo data and addons directories to the user used by the Odoo container:</p>
<pre><code class="language-shell">sudo chown -R 100:101 ~/odoo/odoo-data ~/odoo/addons
</code></pre>
<p>Create the environment file:</p>
<pre><code class="language-shell">nano .env
</code></pre>
<p>Add the following configuration. Replace the domain, email address, and passwords with your own values.</p>
<pre><code class="language-plaintext">DOMAIN=odoo.example.com 
LETSENCRYPT_EMAIL=admin@example.com 

POSTGRES_DB=postgres 
POSTGRES_USER=odoo 
POSTGRES_PASSWORD=STRONG_DATABASE_PASSWORD 

ODOO_DB_HOST=db 
ODOO_DB_PORT=5432 
ODOO_DB_USER=odoo 
ODOO_DB_PASSWORD=STRONG_DATABASE_PASSWORD

ODOO_ADMIN_PASSWORD=STRONG_ADMIN_PASSWORD
</code></pre>
<p>Save and close the file.</p>
<h4 id="heading-create-the-docker-compose-configuration">Create the Docker Compose Configuration</h4>
<p>Create the Docker Compose file like this:</p>
<pre><code class="language-shell">nano docker-compose.yml
</code></pre>
<p>Add the following configuration:</p>
<pre><code class="language-yaml">services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    restart: unless-stopped

  db:
    image: postgres:15
    container_name: odoo-db
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    restart: unless-stopped

  odoo:
    image: odoo:19.0
    container_name: odoo
    depends_on:
      - db
    environment:
      HOST: ${ODOO_DB_HOST}
      PORT: ${ODOO_DB_PORT}
      USER: ${ODOO_DB_USER}
      PASSWORD: ${ODOO_DB_PASSWORD}
    command:
      - "--admin-passwd=${ODOO_ADMIN_PASSWORD}"
    volumes:
      - ./odoo-data:/var/lib/odoo
      - ./addons:/mnt/extra-addons
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.odoo.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.odoo.entrypoints=websecure"
      - "traefik.http.routers.odoo.tls=true"
      - "traefik.http.routers.odoo.tls.certresolver=letsencrypt"
      - "traefik.http.services.odoo.loadbalancer.server.port=8069"
    restart: unless-stopped
</code></pre>
<p>Start the services in detached mode:</p>
<pre><code class="language-shell">docker compose up -d
</code></pre>
<p>Verify that all services are running:</p>
<pre><code class="language-shell">docker compose ps
</code></pre>
<p>Check the Odoo logs:</p>
<pre><code class="language-shell">docker compose logs odoo --tail=50
</code></pre>
<h4 id="heading-configure-postgresql">Configure PostgreSQL</h4>
<p>Check the privileges of the <code>odoo</code> PostgreSQL role:</p>
<pre><code class="language-shell">docker exec -it odoo-db psql -U odoo -d postgres -c "\du"
</code></pre>
<p>Confirm that the <code>odoo</code> role has the <code>Create DB</code> attribute.</p>
<p>If the attribute is missing, grant it using the PostgreSQL administrator account.</p>
<pre><code class="language-shell">docker exec -it odoo-db psql -U postgres -d postgres -c "ALTER ROLE odoo CREATEDB;"
</code></pre>
<p>Verify the privilege again:</p>
<pre><code class="language-shell">docker exec -it odoo-db psql -U odoo -d postgres -c "\du"
</code></pre>
<h4 id="heading-access-and-configure-odoo">Access and Configure Odoo</h4>
<p>Open <code>https://odoo.example.com</code> in your browser, replacing the domain with your own.</p>
<p>Then click <strong>Create Database</strong>.</p>
<p>Enter the master password from <code>ODOO_ADMIN_PASSWORD</code> in your <code>.env</code> file. Enter a database name, such as <code>odoo</code>. And enter the administrator email and password.</p>
<p>Select your language and country and click <strong>Create Database</strong>. Odoo creates the database and opens the dashboard. Log in with your administrator credentials.</p>
<h2 id="heading-managed-odoo-hosting">Managed Odoo Hosting</h2>
<p>Managed Odoo hosting is a deployment model where a hosting provider manages the underlying infrastructure and routine maintenance tasks. Instead of provisioning and maintaining servers yourself, you rely on the provider to manage the hosting environment, allowing your team to focus on using Odoo for day-to-day business operations.</p>
<p>Managed Odoo hosting is available through Odoo itself using <a href="http://Odoo.sh">Odoo.sh</a>, as well as through third-party providers such as <a href="https://cloudpepper.io/">CloudPepper</a> and <a href="https://www.rosehosting.com/">RoseHosting</a>. The level of server access, customization, maintenance, and infrastructure management varies between providers, so it's important to review what each provider includes before choosing a service.</p>
<h3 id="heading-benefits-of-managed-hosting">Benefits of Managed Hosting</h3>
<p>Managed hosting simplifies Odoo deployment by reducing the effort required to maintain the underlying infrastructure. Depending on the provider and plan, common benefits may include:</p>
<ul>
<li><p>Faster deployment without extensive server setup.</p>
</li>
<li><p>Assistance with software updates and security maintenance.</p>
</li>
<li><p>Automated backups and disaster recovery options.</p>
</li>
<li><p>Infrastructure monitoring and performance management.</p>
</li>
<li><p>Technical support for infrastructure-related issues.</p>
</li>
<li><p>Easier scaling as business requirements grow.</p>
</li>
</ul>
<h3 id="heading-limitations-of-managed-hosting">Limitations of Managed Hosting</h3>
<p>While managed hosting offers convenience, it also comes with certain trade-offs. Organizations should consider the following:</p>
<ul>
<li><p>Limited control over the underlying server environment.</p>
</li>
<li><p>Fewer customization options compared to self-hosting, depending on the provider.</p>
</li>
<li><p>Provider-specific restrictions on server access or configurations.</p>
</li>
<li><p>Recurring hosting or subscription costs.</p>
</li>
<li><p>Dependence on the provider for certain maintenance and infrastructure tasks.</p>
</li>
</ul>
<h3 id="heading-who-should-choose-managed-odoo-hosting">Who Should Choose Managed Odoo Hosting?</h3>
<p>Managed Odoo hosting can be a good choice for:</p>
<ul>
<li><p>Small and medium-sized businesses.</p>
</li>
<li><p>Organizations without dedicated IT or DevOps teams.</p>
</li>
<li><p>Teams that want to reduce the effort of managing infrastructure.</p>
</li>
<li><p>Businesses looking for a faster and simpler deployment.</p>
</li>
<li><p>Organizations that prefer a low-maintenance hosting solution.</p>
</li>
</ul>
<h2 id="heading-self-hosted-vs-managed-hosting">Self-Hosted vs Managed Hosting</h2>
<p>Both self-hosted and managed hosting allow you to deploy and run Odoo. The right choice depends on your organization's technical expertise, operational requirements, customization needs, and budget. The following table compares the key differences between the two hosting models.</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Self-Hosted Odoo</th>
<th>Managed Odoo Hosting</th>
</tr>
</thead>
<tbody><tr>
<td>Setup</td>
<td>Install and configure Odoo yourself</td>
<td>Provider handles deployment and initial setup</td>
</tr>
<tr>
<td>Infrastructure Management</td>
<td>Managed by your organization</td>
<td>Managed by the hosting provider</td>
</tr>
<tr>
<td>Server Control</td>
<td>Full control over the server environment</td>
<td>Limited server-level control</td>
</tr>
<tr>
<td>Customization</td>
<td>Extensive customization and configuration options</td>
<td>May be limited by provider policies</td>
</tr>
<tr>
<td>Updates</td>
<td>Managed internally</td>
<td>Typically handled or supported by the provider</td>
</tr>
<tr>
<td>Security</td>
<td>Organization manages security patches and server hardening</td>
<td>Provider manages infrastructure security and may handle security updates</td>
</tr>
<tr>
<td>Backups</td>
<td>Configured and maintained by your organization</td>
<td>Often automated, depending on the provider</td>
</tr>
<tr>
<td>Monitoring</td>
<td>Managed internally</td>
<td>Often provided by the hosting provider</td>
</tr>
<tr>
<td>Technical Expertise</td>
<td>Requires Linux and server administration skills</td>
<td>Less infrastructure expertise required</td>
</tr>
<tr>
<td>Scalability</td>
<td>Organization manages infrastructure scaling</td>
<td>Often easier to scale through the provider</td>
</tr>
<tr>
<td>Support</td>
<td>Internal IT team or community support</td>
<td>Technical support provided by the hosting provider</td>
</tr>
<tr>
<td>Cost</td>
<td>Infrastructure costs plus maintenance effort</td>
<td>Recurring hosting fees with reduced maintenance overhead</td>
</tr>
</tbody></table>
<p>Self-hosting is a good choice for organizations that need greater control and customization, while managed hosting is better suited for teams that want to reduce the effort of managing infrastructure and focus on business operations. The right option depends on your technical expertise, operational requirements, customization needs, and long-term business goals.</p>
<h2 id="heading-how-to-choose-the-right-option">How to Choose the Right Option</h2>
<p>Choosing between self-hosted and managed Odoo hosting depends on your organization's technical expertise, business requirements, budget, and how much time your team can dedicate to managing infrastructure.</p>
<h3 id="heading-choose-self-hosted-odoo-if">Choose Self-Hosted Odoo If</h3>
<p>Self-hosting may be a better fit if you:</p>
<ul>
<li><p>Have an in-house IT or DevOps team with Linux and cloud administration experience.</p>
</li>
<li><p>Need full control over the server environment.</p>
</li>
<li><p>Require extensive customization or third-party integrations.</p>
</li>
<li><p>Have specific security, compliance, or performance requirements.</p>
</li>
<li><p>Are prepared to manage updates, backups, monitoring, and troubleshooting.</p>
</li>
</ul>
<h3 id="heading-choose-managed-odoo-hosting-if">Choose Managed Odoo Hosting If</h3>
<p>Managed hosting may be a better fit if you:</p>
<ul>
<li><p>Want to deploy Odoo without managing the underlying infrastructure.</p>
</li>
<li><p>Don't have dedicated IT or DevOps resources.</p>
</li>
<li><p>Prefer a provider to handle routine maintenance and infrastructure management.</p>
</li>
<li><p>Want to reduce the operational work involved in maintaining servers.</p>
</li>
<li><p>Prefer a low-maintenance solution that allows your team to focus on business operations.</p>
</li>
</ul>
<p>The right hosting model depends on how much control your organization needs and how much infrastructure management it is prepared to handle. Consider your technical skills, customization requirements, budget, and long-term business needs before making a decision.</p>
<h2 id="heading-key-factors-to-consider-before-choosing">Key Factors to Consider Before Choosing</h2>
<p>Choosing the right hosting option involves more than comparing features or costs. Consider the following factors before deciding how to host Odoo.</p>
<ul>
<li><p><strong>Budget:</strong> Consider both the initial and ongoing costs. Self-hosting requires infrastructure and maintenance, while managed hosting usually involves recurring hosting fees in exchange for less infrastructure work.</p>
</li>
<li><p><strong>Technical Expertise:</strong> Consider whether your team has the skills to install, maintain, secure, and troubleshoot Odoo. If you don't have dedicated IT or DevOps resources, managed hosting may be easier to maintain.</p>
</li>
<li><p><strong>Customization:</strong> If you need custom modules, third-party integrations, or specific server configurations, check whether your hosting option supports them. Self-hosting generally provides more control over customization.</p>
</li>
<li><p><strong>Security and Compliance:</strong> Consider your security policies, data protection requirements, and any industry regulations that apply to your organization. Also determine which security responsibilities belong to your team and which are handled by the hosting provider.</p>
</li>
<li><p><strong>Scalability:</strong> Consider how your Odoo deployment may grow over time. Your hosting environment should be able to support increases in users, data, and workloads.</p>
</li>
<li><p><strong>Maintenance and Support:</strong> Decide whether your team is prepared to manage updates, backups, monitoring, and troubleshooting or whether you would prefer a provider to handle these responsibilities.</p>
</li>
</ul>
<p>Consider these factors together rather than focusing on a single one. The right hosting solution should match your organization's technical skills, business requirements, budget, and long-term plans.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Choosing the right hosting model is an important part of planning a successful Odoo deployment. Self-hosting offers greater control, flexibility, and customization, while managed hosting reduces the effort required to maintain the infrastructure.</p>
<p>Each approach has its own advantages, and the best choice depends on your organization's technical expertise, business requirements, and long-term goals.</p>
<p>Before making a decision, evaluate factors such as your budget, customization needs, security requirements, scalability, and the resources available to manage the deployment. By selecting the hosting model that aligns with your priorities, you can build a reliable and maintainable foundation for running Odoo.</p>
<p>If you'd like to read more hands-on deployment tutorials and technical documentation, visit my portfolio at <a href="https://docs.abdultalha.dev/">docs.abdultalha.dev</a>. You can also connect with me on <a href="https://www.linkedin.com/in/abdul-talha/">LinkedIn</a> to follow my latest articles and open-source work.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Chrome Dino Game Works Under the Hood: A Tour of Chromium's Source Code ]]>
                </title>
                <description>
                    <![CDATA[ You've seen it a hundred times: the Wi-Fi drops, Chrome shrugs, and a little pixelated T-Rex appears, ready to sprint through a desert the moment you hit the spacebar. That tiny game, hidden behind th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-chrome-dino-game-works/</link>
                <guid isPermaLink="false">6a7e02796c61d1c629897f7c</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Game Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Oliinyk ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 17:44:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/dfca07f2-cf19-46c5-9c46-dad380bd0ed4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've seen it a hundred times: the Wi-Fi drops, Chrome shrugs, and a little pixelated T-Rex appears, ready to sprint through a desert the moment you hit the spacebar.</p>
<p>That tiny game, hidden behind the "No Internet" error since 2014, is played roughly 270 million times every month. Its internal codename at Google was "Project Bolan," a nod to Marc Bolan, frontman of the rock band T. Rex.</p>
<p>And because Chrome is built on the open-source Chromium project, the entire game – every constant, design decision, and hack – is sitting in public for anyone to read.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/5d2fb3c2-7ee8-47d8-90fa-de05a6cad06f.png" alt="The Chrome dino world, assembled from the original sprite sheet" style="display: block;" width="1500" height="780" loading="lazy">

<p>So I read it. All of it. And it turns out this "simple" game is a small masterclass in game design: it quietly onboards you, refuses to kill you unfairly, animates a moon through seven phases, and has been shipping a typo to billions of devices for a decade.</p>
<p>In this article, we'll walk through the real source code and unpack how the dino game actually works. If you want the game open in a tab while you read, you don't need to kill your Wi-Fi. You can play the <a href="https://chromedino.com/">Dinosaur Game</a> online, and freeCodeCamp also has a <a href="https://www.freecodecamp.org/news/how-to-play-the-no-internet-google-chrome-dinosaur-game-both-online-and-offline/">guide to launching it on and offline</a>.</p>
<p>And the code itself lives in the Chromium tree, browsable at <a href="https://source.chromium.org/chromium/chromium/src/+/main:components/neterror/resources/dino_game/">source.chromium.org</a>. This is historically a single file of roughly 3,000 lines of dependency-free vanilla JavaScript, drawing on a plain <code>&lt;canvas&gt;</code>. No engine. No framework. Not even jQuery.</p>
<p>By the way, here's the entire game's artwork. It's one small PNG, with every sprite the code refers to by coordinates:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9f00025c-0c09-46b8-821b-8053393fc911.png" alt="The original sprite sheet, annotated: every visual in the game lives in this one image" style="display: block;" width="2466" height="136" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-clock-why-the-game-runs-the-same-speed-everywhere">The Clock: Why the Game Runs the Same Speed Everywhere</a></p>
</li>
<li><p><a href="#heading-the-t-rex-four-constants-and-a-typo">The T-Rex: Four Constants and a Typo</a></p>
</li>
<li><p><a href="#heading-the-fairness-engine-how-the-game-refuses-to-cheat-you">The Fairness Engine: How the Game Refuses to Cheat You</a></p>
</li>
<li><p><a href="#heading-collision-detection-the-dino-is-six-rectangles">Collision Detection: The Dino Is Six Rectangles</a></p>
</li>
<li><p><a href="#heading-night-mode-and-the-seven-phase-moon">Night Mode and the Seven-Phase Moon</a></p>
</li>
<li><p><a href="#heading-the-small-delights-hiding-in-plain-sight">The Small Delights Hiding in Plain Sight</a></p>
</li>
<li><p><a href="#heading-what-you-can-steal-for-your-own-projects">What You Can Steal for Your Own Projects</a></p>
</li>
</ul>
<h2 id="heading-the-clock-why-the-game-runs-the-same-speed-everywhere">The Clock: Why the Game Runs the Same Speed Everywhere</h2>
<p>The first problem every game has to solve: browsers don't repaint at a fixed rate. A 144 Hz gaming monitor fires <code>requestAnimationFrame</code> 144 times a second. But a struggling laptop might manage 40. If you move things a fixed number of pixels per frame, your game literally runs 3× faster on better hardware.</p>
<p>The dino's solution is the standard one, executed cleanly. The game defines its speeds in pixels per frame <em>at an assumed 60 FPS</em>, then scales every movement by how much time actually passed:</p>
<pre><code class="language-javascript">this.msPerFrame = 1000 / FPS;
// ...in each update:
this.xPos -= Math.floor((currentSpeed * FPS / 1000) * deltaTime);
</code></pre>
<p>Every moving thing in the game from the dino's jump to the cacti, clouds, and even the moon, is multiplied by <code>deltaTime</code>. That's why your high score is comparable to your friend's, whatever machines you're both on.</p>
<p>If you take one engineering habit away from this article, take this one: <strong>never move anything by "per frame" amounts. Always scale by elapsed time.</strong></p>
<h2 id="heading-the-t-rex-four-constants-and-a-typo">The T-Rex: Four Constants and a Typo</h2>
<p>The dino's entire physical existence is defined by a handful of numbers in <code>Trex.config</code> and <code>Runner.config</code>:</p>
<pre><code class="language-javascript">GRAVITY: 0.6,
INIITAL_JUMP_VELOCITY: -10,
SPEED: 6,
ACCELERATION: 0.001,
MAX_SPEED: 13,
</code></pre>
<p>Yes, you read that right: <code>INIITAL_JUMP_VELOCITY</code>, with three I's. That misspelling shipped in Chrome, on billions of devices, and has survived for roughly a decade, because renaming it was never worth the risk. Let it comfort you the next time you find a typo in your own production code.</p>
<p>The units are pixels per frame at 60 FPS. Convert them and the physics becomes intuitive: gravity is 0.6 px/frame², jump velocity −10 px/frame. Run the math and the jump arc peaks at about 83 pixels roughly 0.28 seconds after takeoff. On a 150-pixel-tall playfield, that's more than half the screen.</p>
<p>The world starts scrolling at 6 px/frame (360 px/s) and gains 0.001 px/frame every frame until it hits the cap of 13, a little over twice the starting speed. That cap matters: it's the promise that the game gets <em>hard</em>, but never <em>impossible</em>.</p>
<p>But here's the detail most clones miss: <strong>the jump height is variable.</strong> Watch the code that runs when you release the spacebar:</p>
<pre><code class="language-javascript">endJump: function () {
    if (this.reachedMinHeight &amp;&amp;
        this.jumpVelocity &lt; this.config.DROP_VELOCITY) {
        this.jumpVelocity = this.config.DROP_VELOCITY;
    }
},
</code></pre>
<p>Tap the spacebar and the dino does a short hop, but hold it and the dino rides the full arc. Releasing the key early clamps the upward velocity, cutting the jump short (as long as a minimum height was reached, so you can't glitch yourself into a cactus). And if you press the Down arrow mid-air, <code>setSpeedDrop</code> multiplies the fall speed by 3, slamming the dino back to the ground for a fast recovery.</p>
<p>Two tiny mechanics, and suddenly the single-button game has an expressive skill ceiling: short hop, full jump, fast slam. That's why the top players' runs look nothing like yours.</p>
<h2 id="heading-the-fairness-engine-how-the-game-refuses-to-cheat-you">The Fairness Engine: How the Game Refuses to Cheat You</h2>
<p>This is my favorite part of the codebase, because none of it is visible. You can only <em>feel</em> it. Every obstacle in the game is declared with a small config, and the configs encode a set of fairness rules. Here's the small cactus:</p>
<pre><code class="language-javascript">{
    type: 'CACTUS_SMALL',
    width: 17,
    height: 35,
    multipleSpeed: 4,
    minGap: 120,
    minSpeed: 0,
    // ...
}
</code></pre>
<p>Let's unpack the rules hiding in there and elsewhere in the spawning code:</p>
<p><strong>Rule 1: The first three seconds are empty.</strong> <code>CLEAR_TIME: 3000</code> guarantees no obstacle spawns for the first three seconds of a run. That's silent onboarding: you get a moment to feel the controls before the game asks anything of you.</p>
<p><strong>Rule 2: Clusters are gated by speed.</strong> A cactus can spawn as a group of up to <code>MAX_OBSTACLE_LENGTH: 3</code>, but only when the current speed exceeds its <code>multipleSpeed</code> (4 for small cacti, 7 for large). Why? Because your jump <em>distance</em> grows with the world speed. The arc lasts a fixed time, so the faster the ground moves, the more ground you clear per jump.</p>
<p>Wide obstacles only appear once your jump is physically long enough to clear them. The game never generates a wall it knows you can't cross.</p>
<p><strong>Rule 3: Gaps scale with speed too.</strong> The gap after each obstacle is computed as roughly <code>obstacleWidth × speed + minGap × 0.6</code>, plus randomness. So the faster the game, the more room you're given to react. Difficulty comes from the speed itself, never from unfair spacing.</p>
<p><strong>Rule 4: No obstacle appears three times in a row.</strong> There's a function whose entire job is variety:</p>
<pre><code class="language-javascript">duplicateObstacleCheck: function (nextObstacleType) {
    var duplicateCount = 0;
    for (var i = 0; i &lt; this.obstacleHistory.length; i++) {
        duplicateCount = this.obstacleHistory[i] == nextObstacleType ?
            duplicateCount + 1 : 0;
    }
    return duplicateCount &gt;= Runner.config.MAX_OBSTACLE_DUPLICATION;
},
</code></pre>
<p>With <code>MAX_OBSTACLE_DUPLICATION: 2</code>, the spawner keeps a history and re-rolls if the same obstacle type would appear a third consecutive time. You've never noticed this rule, which is exactly the point. You'd have noticed its absence.</p>
<p><strong>Rule 5: The pterodactyl is a late-game boss.</strong> Its config says <code>minSpeed: 8.5</code>, meaning it can't appear at all until you're two-thirds of the way to max speed. It flies at one of three heights (so sometimes you jump it, sometimes you duck, and sometimes at 50 pixels you must decide), it never spawns in groups (<code>multipleSpeed: 999</code>), and it has its own <code>speedOffset: 0.8</code>, meaning each pterodactyl flies slightly faster or slower than the world scrolls. That last detail breaks your rhythm-based muscle memory precisely when you've gotten comfortable.</p>
<p>Together these rules are the answer to a question every game designer faces: how do you make a game <em>harder</em> without making it <em>unfair</em>? Players can feel the difference between "I lost because I was slow" and "I lost because the game cheated". And the dino, in ten years and trillions of runs, has never cheated anyone.</p>
<h2 id="heading-collision-detection-the-dino-is-six-rectangles">Collision Detection: The Dino Is Six Rectangles</h2>
<p>Naïve collision detection would wrap the dino in one bounding box and check overlap. But look at the dino: he has a snout sticking out, a tail, a gap under his chin. With a single box, a cactus grazing the empty air under his jaw would kill you, and it would feel terrible.</p>
<p>So the real dino is six boxes:</p>
<pre><code class="language-javascript">Trex.collisionBoxes = {
    RUNNING: [
        new CollisionBox(22, 0, 17, 16),   // head
        new CollisionBox(1, 18, 30, 9),    // torso
        new CollisionBox(10, 35, 14, 8),   // legs
        new CollisionBox(1, 24, 29, 5),
        new CollisionBox(5, 30, 21, 4),
        new CollisionBox(9, 34, 15, 4)
    ],
    DUCKING: [
        new CollisionBox(1, 18, 55, 25)    // one long low box
    ]
};
</code></pre>
<p>Six small rectangles that trace the dino's actual silhouette: head, torso, and a staircase of boxes down the belly and legs. When the dino ducks, the whole set is swapped for one long, low box.</p>
<p>The obstacles get the same treatment: a small cactus is three boxes tracing its trunk and arms, and the pterodactyl is <em>five</em>, following its wings and beak.</p>
<p>Here's what those boxes actually look like, drawn over the real sprites:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9a0dc54d-f59d-4427-8f18-cb42b2fa9092.png" alt="The real collision boxes from the source code, drawn over the sprites: 6 for the running T-Rex, 3 for a large cactus, 5 for the pterodactyl" style="display: block;" width="1060" height="470" loading="lazy">

<p>The algorithm is a classic two-phase check: first a cheap outer-box test (do the overall rectangles even touch?), and only if that passes, the detailed loop comparing every dino box against every obstacle box. Fast in the common case, precise in the moment that matters. If a cactus needle visually passes through the notch under the dino's chin, you live, and the code agrees with your eyes.</p>
<p>This is the cheapest possible version of a technique that scales all the way up to fighting-game hitboxes and hurtboxes. The lesson generalizes: <strong>collision should match what the player sees, not what's convenient for the math.</strong></p>
<h2 id="heading-night-mode-and-the-seven-phase-moon">Night Mode and the Seven-Phase Moon</h2>
<p>Reach 700 points (<code>INVERT_DISTANCE: 700</code>) and the world inverts: dark sky, pale ground, stars. Twelve seconds later (<code>INVERT_FADE_DURATION: 12000</code>), day returns. Numerically it's just a class toggle plus a CSS-style inversion of the palette. But the charming part is what happens in the sky.</p>
<p>The moon isn't a static sprite. The sprite sheet contains it in seven versions, and the code cycles through them:</p>
<pre><code class="language-javascript">NightMode.phases = [140, 120, 100, 60, 40, 20, 0];
</code></pre>
<p>Those are x-offsets into the sprite sheet, from a thin crescent through the half moons to a full disc:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/9823b7ea-c309-4db3-8e16-fcda9211f9af.png" alt="All seven moon phases, cropped straight from the sprite sheet at the offsets the code defines" style="display: block;" width="968" height="290" loading="lazy">

<p>Every time night falls, the moon advances one phase. Stars drift at their own speed (<code>STAR_SPEED: 0.3</code>), slower than the ground, giving the night a whisper of parallax depth. Nobody needed a lunar calendar in a browser error page. Somebody built one anyway, and that somebody understood that details like this are the difference between a feature and a beloved thing.</p>
<h2 id="heading-the-small-delights-hiding-in-plain-sight">The Small Delights Hiding in Plain Sight</h2>
<p>A few more finds from the source that reward the attentive:</p>
<p><strong>The dino blinks.</strong> While the game waits for you to start, the idle dino blinks at randomized intervals. And there's a constant, <code>MAX_BLINK_COUNT: 3</code>, limiting how many times he'll do it. The blink delay itself is <code>Math.ceil(Math.random() * Trex.BLINK_TIMING)</code>. Someone at Google tuned the randomness of a dinosaur's eyelid.</p>
<p><strong>Your score isn't pixels.</strong> The distance meter multiplies actual pixels traveled by <code>COEFFICIENT: 0.025</code>. So a score of 100 means you've run 4,000 pixels. Every 100 points (<code>ACHIEVEMENT_DISTANCE: 100</code>), the score flashes at four beats per second – a tiny dopamine metronome that makes round numbers feel like events.</p>
<p><strong>The counter is theatrical about overflow.</strong> The display shows <code>MAX_DISTANCE_UNITS: 5</code> digits. Roll past 99,999 and the score visually resets. The internal counter keeps going, but the odometer effect stays, a deliberate homage to arcade cabinets.</p>
<p><strong>Mobile players get a handicap.</strong> <code>MOBILE_SPEED_COEFFICIENT: 1.2</code>: the game runs faster... wait, no: it adjusts for the smaller screens and touch latency so the experience feels equivalent. The point is that someone measured the difference between a thumb on glass and a finger on a spacebar, and encoded the answer in a constant.</p>
<p><strong>Restart is protected.</strong> After a crash there's a <code>GAMEOVER_CLEAR_TIME: 750</code>. For three-quarters of a second, your jump key won't restart the game. That's there because you <em>will</em> be hammering the spacebar when you die, and instantly restarting would rob you of the chance to see your score. A 750-millisecond act of mercy.</p>
<h2 id="heading-what-you-can-steal-for-your-own-projects">What You Can Steal for Your Own Projects</h2>
<p>The dino game is a masterclass precisely because its constraints were brutal: it had to be tiny, load instantly, run on everything from gaming rigs to $50 phones, and be understood by anyone in one second.</p>
<p>The techniques it uses under those constraints transfer to any project:</p>
<ul>
<li><p><strong>Scale by time, not frames.</strong> Delta-time movement is why the game is fair across hardware.</p>
</li>
<li><p><strong>Gate difficulty behind capability.</strong> Wide clusters appear only when the jump can clear them. Ask what the player <em>can do</em>, then spawn accordingly.</p>
</li>
<li><p><strong>Give the player an empty runway.</strong> Three quiet seconds teach the controls better than a tutorial screen.</p>
</li>
<li><p><strong>Enforce variety.</strong> A three-line history check prevents monotony the player would notice only as vague boredom.</p>
</li>
<li><p><strong>Make hitboxes honest.</strong> Six rectangles that match the silhouette beat one rectangle that betrays the player's eyes.</p>
</li>
<li><p><strong>Spend effort on invisible details.</strong> Blinking, moon phases, the restart grace period: none are necessary, but all are felt. Ten years on, the Chrome dino is proof that a great game doesn't need photorealistic graphics or a 100-gigabyte install. It just needs tight controls, fair rules, one button, and a moon that keeps its phases. Now you know exactly why it feels so good: because someone, line by line, made sure it would.</p>
</li>
</ul>
<p>Go read the source. It's one of the best free game design lessons on the internet, and it's been hiding behind your worst Wi-Fi days all along.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make Your Antigravity Agent Skills Configurable (Without Forking Them) ]]>
                </title>
                <description>
                    <![CDATA[ Antigravity Agent Skills are a great way to teach your AI agent a workflow once and reuse it everywhere. You write a short SKILL.md file, drop it in a folder, and the agent picks it up whenever it's r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/make-your-antigravity-agent-skills-configurable-without-forking-them/</link>
                <guid isPermaLink="false">6a69c58763daca7bbbf2320b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Antigravity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Obum ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 09:19:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/7edaf407-ce56-4eff-8b1c-5e1c31e71067.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Antigravity Agent Skills are a great way to teach your AI agent a workflow once and reuse it everywhere. You write a short <code>SKILL.md</code> file, drop it in a folder, and the agent picks it up whenever it's relevant.</p>
<p>But these skills have a hidden limitation: they're static. If you download a skill someone else wrote and you want it to behave a little differently, you'll have to copy the whole thing and edit it by hand. And as you may have noticed lately, there are many "skills" forks floating around that are difficult to maintain.</p>
<p>In this tutorial, I'll show you I built a small convention that fixes this. It lets any Agent Skill read a per-project config file, so you can adopt any skill and customize how it behaves by editing a few lines of YAML (without ever touching the skill itself).</p>
<p>You'll build it step by step, test it, and see how to share it so other people can plug into it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-antigravity-agent-skills">What Are Antigravity Agent Skills</a>?</p>
</li>
<li><p><a href="#heading-why-static-skills-are-a-problem">Why Static Skills Are a Problem</a></p>
</li>
<li><p><a href="#heading-the-configurable-skills-solution">The Configurable Skills Solution</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-config-loader">How to Build the Config Loader</a></p>
</li>
<li><p><a href="#heading-how-to-make-a-skill-configurable">How to Make a Skill Configurable</a></p>
</li>
<li><p><a href="#heading-how-to-add-project-overrides">How to Add Project Overrides</a></p>
</li>
<li><p><a href="#heading-how-to-test-your-configurable-skill">How to Test Your Configurable Skill</a></p>
</li>
<li><p><a href="#heading-two-more-example-skills">Two More Example Skills</a></p>
</li>
<li><p><a href="#heading-how-to-share-your-agent-skills-with-others">How to Share Your Agent Skills With Others</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>You will build a tiny, reusable layer called <strong>Configurable Agent Skills</strong>. It has three parts:</p>
<ol>
<li><p>A small Python script, <code>resolve_config.py</code>, that merges a skill's default settings with your project settings and prints the result.</p>
</li>
<li><p>A convention: each skill ships 2 files, a <code>config.default.yaml</code> file with its "knobs" and a <code>SKILL.md</code> file. They both guide the agent's behavior.</p>
</li>
<li><p>A per-project file, <code>.agent/skills.config.yaml</code>, where anyone using your skill sets their own values.</p>
</li>
</ol>
<p>By the end, you'll have a working <code>git-commit-formatter</code> skill that one team can run in Conventional Commits mode and another team can switch to gitmoji mode, all using the exact same skill files with no forking.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p>Google Antigravity installed (the IDE, CLI, or SDK. Any of them work, since skills are just files.).</p>
</li>
<li><p>Python 3 installed, with PyYAML. You can install PyYAML with <code>python -m pip install pyyaml</code>.</p>
</li>
<li><p>Basic comfort with the terminal and YAML. You don't need to be an expert in either.</p>
</li>
</ul>
<p>If you've never written an Agent Skill before, the next two sections will bring you up to speed.</p>
<h2 id="heading-what-are-antigravity-agent-skills">What Are Antigravity Agent Skills?</h2>
<p>A Skill in Antigravity is a folder that contains a <code>SKILL.md</code> file and, optionally, some scripts, templates, or examples. The <code>SKILL.md</code> file has a short block of YAML "frontmatter" at the top (a <code>name</code> and a <code>description</code>), followed by a set of instructions written in plain Markdown.</p>
<p>Here's the important part: skills are loaded on demand. The agent reads only the short <code>description</code> of each skill at first. When your request matches that description, the agent pulls in the full instructions and follows them. This keeps the agent's context small and focused.</p>
<p>A minimal skill that enforces Conventional Commits looks like this:</p>
<pre><code class="language-markdown">---
name: git-commit-formatter
description: Formats git commit messages using the Conventional Commits specification. Use this when the user asks to commit changes or write a commit message.
---

# Git Commit Formatter

When writing a commit message, follow the Conventional Commits format:
`type(scope): description`

Allowed types: feat, fix, docs, style, refactor, perf, test, chore.
</code></pre>
<p>Drop that in your skills folder, ask the agent to "commit these changes," and it will write a properly formatted message. Simple and useful, right?</p>
<h2 id="heading-why-static-skills-are-a-problem">Why Static Skills Are a Problem</h2>
<p>Now look closely at that skill. The allowed types (<code>feat</code>, <code>fix</code>, <code>docs</code>, and so on) are baked directly into the instructions.</p>
<p>That's fine until someone wants something slightly different. Maybe your team also uses a <code>ci</code> type. Maybe you prefer gitmoji, where each commit starts with an emoji. Maybe you want to require a scope on every commit.</p>
<p>With a static skill, there's only one way to get any of that: copy the whole skill and edit the Markdown. When you do this across a team, everyone ends up with their own private fork. When the original author ships an improvement, none of the forks get it. The skill stops being something you <em>share</em> and becomes something everyone <em>rewrites</em>.</p>
<p>The core issue is that there's no clean line between the skill's logic (which everyone should share) and its settings (which each project wants to control). How do we solve this?</p>
<h2 id="heading-the-configurable-skills-solution">The Configurable Skills Solution</h2>
<p>The idea is simple. Instead of hard-coding settings in the instructions, the skill will:</p>
<ol>
<li><p>Ship its settings and their defaults in a separate <code>config.default.yaml</code> file.</p>
</li>
<li><p>Read a merged config (defaults plus any project-level overrides) before it acts.</p>
</li>
</ol>
<p>The project-level overrides live in a file called <code>.agent/skills.config.yaml</code>, which sits at the root of the user's project:</p>
<pre><code class="language-yaml"># .agent/skills.config.yaml 
# (edit this file in your project instead of the skill globally)
git-commit-formatter:
  style: gitmoji
  extra_types: [ci, build]
  scope_required: true
</code></pre>
<p>That's the easy flow. Drop the skill in, set a few keys, and you're done. The skill's own files never change.</p>
<p>To make this work, you need a script that reads both files, merges them, and hands the result to the agent. Let's build it.</p>
<h2 id="heading-how-to-build-the-config-loader">How to Build the Config Loader</h2>
<p>Create a file called <code>resolve_config.py</code>. Its job is to take a skill's name, load that skill's <code>config.default.yaml</code>, find the user's <code>.agent/skills.config.yaml</code>, and merge the two so that user values win.</p>
<p>Start with a deep-merge helper. This is the heart of the loader:</p>
<pre><code class="language-python">def deep_merge(base, override):
    """Recursively merge override onto base.

    Dicts merge key by key. Anything else (scalars, lists) is replaced
    wholesale by the override value.
    """
    if isinstance(base, dict) and isinstance(override, dict):
        merged = dict(base)
        for key, value in override.items():
            merged[key] = deep_merge(merged[key], value) if key in merged else value
        return merged
    return override
</code></pre>
<p>Notice the deliberate choice here: dictionaries merge key by key, but lists are replaced, not appended. That keeps the behavior predictable. If you want to handle "defaults plus extras", use the explicit <code>extra_types</code> key in the skill as you'll see in the example below.</p>
<p>Next, you need to find your "per-project" config. The loader walks up from the current directory looking for an <code>.agent/skills.config.yaml</code> file:</p>
<pre><code class="language-python">from pathlib import Path

def find_project_config(start: Path):
    """Walk upward from start looking for .agent/skills.config.yaml."""
    start = start.resolve()
    for folder in [start, *start.parents]:
        candidate = folder / ".agent" / "skills.config.yaml"
        if candidate.is_file():
            return candidate
    return None
</code></pre>
<p>Now put it together. The loader locates the skill's defaults (which sit next to the script), loads your overrides for that skill's name, merges them, and prints the result:</p>
<pre><code class="language-python">import sys, yaml
from pathlib import Path

def resolve(skill_name, skill_dir, project_root):
    defaults = yaml.safe_load((Path(skill_dir) / "config.default.yaml").read_text()) or {}

    user_path = find_project_config(Path(project_root))
    user_all = yaml.safe_load(user_path.read_text()) if user_path else {}
    user_cfg = (user_all or {}).get(skill_name, {}) or {}

    return deep_merge(defaults, user_cfg)
</code></pre>
<p>That completes the whole idea. The full version in the sample repo adds a command-line interface, JSON output, and clear error messages, but the logic above is all you really need.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f92a5e56aa1ed54804bb866/ca8d7095-21f6-4433-8c82-b6b0ca34b9ef.png" alt="Terminal output showing the resolved configuration for the git-commit-formatter skill." style="display: block;" width="1380" height="900" loading="lazy">

<h2 id="heading-how-to-make-a-skill-configurable">How to Make a Skill Configurable</h2>
<p>Now you'll convert the static commit skill into a configurable one. This takes two files.</p>
<p>First, create <code>config.default.yaml</code> next to the skill. It lists every setting and a safe default, so the skill works even when the user has no config at all:</p>
<pre><code class="language-yaml"># Default configuration for the git-commit-formatter skill.
style: conventional          # conventional | gitmoji
types:                       # base set of allowed commit types
  - feat
  - fix
  - docs
  - style
  - refactor
  - perf
  - test
  - chore
extra_types: []              # additional types, merged on top of `types`
scope_required: false        # if true, require a scope: type(scope): ...
max_subject_length: 72       # hard cap on the subject line
</code></pre>
<p>Second, update <code>SKILL.md</code> so that its very first instruction is to resolve the config and apply it. This is the key move: you're telling the agent to read the settings before it does anything else:</p>
<pre><code class="language-markdown">---
name: git-commit-formatter
description: Formats git commit messages to a team's chosen convention (Conventional Commits or gitmoji). Use this when the user asks to commit changes or write a commit message. Reads per-project settings so teams customize commit style without editing this skill.
---

# Git Commit Formatter (Configurable)

## Step 1 - Resolve configuration (always do this first)

Run the loader and read its output:

`python scripts/resolve_config.py git-commit-formatter --project-root .`

Apply exactly those settings:

- `style`: `conventional` or `gitmoji`.
- `types` + `extra_types`: the full set of allowed commit types.
- `scope_required`: if true, a scope is mandatory.
- `max_subject_length`: hard cap on the subject line.

## Step 2 - Compose the message

Pick the primary type from `types` + `extra_types`, build the subject in the
chosen `style`, and enforce `scope_required` and `max_subject_length`.
</code></pre>
<p>This pattern ("make the agent run a script and obey its output") is the same one Antigravity's own validation skills use. It keeps the behavior deterministic instead of leaving it to the model's memory.</p>
<p>Notice how <code>extra_types</code> solves the additive-list question. The default list stays put, and the user's extras are simply added on top by the skill. No fork is required to add a <code>ci</code> type.</p>
<h2 id="heading-how-to-add-project-overrides">How to Add Project Overrides</h2>
<p>Let's say you want gitmoji commits with two extra types. Create a single file in your project:</p>
<pre><code class="language-yaml"># .agent/skills.config.yaml
git-commit-formatter:
  style: gitmoji
  extra_types: [ci, build]
  scope_required: true
</code></pre>
<p>You just changed three lines of config and didn't open the skill or fork any code. The next time the agent commits, it will use this project settings.</p>
<p>And a different project, with no config file at all, keeps getting the sensible Conventional Commits defaults. You have one skill with many behaviors.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f92a5e56aa1ed54804bb866/c9cb7c61-4a6f-4963-94ed-321bb20bde30.png" alt="The agent proposing a commit message that starts with an emoji, driven by the project config.&quot;" style="display: block;" width="1380" height="740" loading="lazy">

<h2 id="heading-how-to-test-your-configurable-skill">How to Test Your Configurable Skill</h2>
<p>You don't need the agent to check that the merge works. Run the loader directly and read the output.</p>
<p>With no overrides, you get the defaults:</p>
<pre><code class="language-bash">$ python scripts/resolve_config.py git-commit-formatter --project-root .
style: conventional
scope_required: false
...
</code></pre>
<p>Now add the <code>.agent/skills.config.yaml</code> override from the last section and run it again:</p>
<pre><code class="language-bash">$ python scripts/resolve_config.py git-commit-formatter --project-root . --print-sources
style: gitmoji
scope_required: true
extra_types:
- ci
- build
types:
- feat
- fix
- docs
...
</code></pre>
<p>The <code>style</code> flipped to <code>gitmoji</code>, <code>scope_required</code> became <code>true</code>, and your extra types appeared (while the base <code>types</code> list stayed intact). That confirms the merge does exactly what you want.</p>
<p>It's worth writing a small automated test too, so a future change to the loader can't silently break the merge. A test can create a fake skill and a fake project config in a temp folder, run the loader, and assert that user values override defaults while untouched defaults survive.</p>
<h2 id="heading-two-more-example-skills">Two More Example Skills</h2>
<p>The same pattern works for any skill. Here are two more to show the range.</p>
<h3 id="heading-a-changelog-generator">A Changelog Generator</h3>
<p>Its <code>config.default.yaml</code> exposes the output <code>format</code> (like Keep a Changelog), which commit <code>types</code> to include, and whether to link commit hashes to a repo URL. One project can generate a formal changelog grouped by type, while another can generate a simple bulleted list. It's the same skill with a different config.</p>
<pre><code class="language-yaml"># changelog-generator config.default.yaml (excerpt)
format: keepachangelog       # keepachangelog | conventional | simple
include_types: [feat, fix, perf]
include_authors: false
repo_url: ""                 # if set, hashes link to commits
</code></pre>
<h3 id="heading-a-license-header-adder">A License-Header Adder</h3>
<p>Its config exposes the <code>license</code> (Apache-2.0, MIT, or custom), the <code>holder</code>, and a map of file extensions to comment styles. A company sets the holder once in their project config, and every new file gets the right header in the right comment style, without editing the skill.</p>
<pre><code class="language-yaml"># license-header-adder config.default.yaml (excerpt)
license: apache-2.0          # apache-2.0 | mit | custom
holder: "Your Name or Org"
year: auto                   # auto = current year
</code></pre>
<p>The lesson is that almost any skill has a few decisions baked into it. When you pull those decisions into a <code>config.default.yaml</code>, you convert a one-off skill into a tool that anyone can reuse and tune.</p>
<h2 id="heading-how-to-share-your-agent-skills-with-others">How to Share Your Agent Skills With Others</h2>
<p>Once your agent skills follow the convention, they compose into something bigger. To make your agent skills easy for others to adopt, you have to:</p>
<ul>
<li><p><strong>Keep each skill self-contained:</strong> Vendor a copy of <code>resolve_config.py</code> inside each skill's <code>scripts/</code> folder, so someone can copy a single skill folder anywhere and it just works.</p>
</li>
<li><p><strong>Document every config key</strong> in the <code>SKILL.md</code>, so users know exactly what they can tune.</p>
</li>
<li><p><strong>Publish a small index:</strong> A simple <code>index.json</code> that lists each skill's name, path, and config keys makes it easy for others to discover what you've built and contribute their own.</p>
</li>
</ul>
<p>Because the convention is just "read a config file first," anyone can publish a compatible skill. Each new configurable skill makes the whole ecosystem more useful. In addition to shipping a skill, you're shipping a small standard that other people can build on.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>You started with a static skill whose behavior was frozen in Markdown, and you turned it into a configurable one that anyone can tune from a single project file.</p>
<p>The entire setup is relatively small. It has the merge function, one convention, and a <code>config.default.yaml</code> per skill.</p>
<p>It also changes how skills are shared. Instead of forking a skill to change one setting, you can keep the shared logic and adjust your own config. Improvements to the skill flow to everyone, and everyone still gets the behavior they want.</p>
<p>If you want to try it, build the <code>git-commit-formatter</code> skill from this tutorial, drop it into your Antigravity skills folder, and add an <code>.agent/skills.config.yaml</code> to a project. Then flip <code>style</code> from <code>conventional</code> to <code>gitmoji</code> and watch the same skill behave differently.</p>
<p>From there, make one of your own skills configurable. Find the settings you baked into the instructions, move them into a <code>config.default.yaml</code>, and let your users take it from there.</p>
<p>The full sample code (the loader, its tests, and all three example skills) is on GitHub at <a href="https://github.com/keepdeploying/configurable-agent-skills">github.com/keepdeploying/configurable-agent-skills</a>.</p>
<p>Thanks for reading. If you build a configurable skill of your own, share it. Let's keep the ecosystem growing.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From LLMs to LangChain: Understanding How Modern AI Applications Actually Work ]]>
                </title>
                <description>
                    <![CDATA[ Typically, when we start experimenting with AI, many of us begin similarly. We try a single LLM call as the core of an app, like this: const response = await llm.chat("Explain Kubernetes"); For a lit ]]>
                </description>
                <link>https://www.freecodecamp.org/news/from-llms-to-langchain-understanding-how-modern-ai-applications-actually-work/</link>
                <guid isPermaLink="false">6a3aab13b5ad15098db82372</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langchain ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sudheesh Shetty ]]>
                </dc:creator>
                <pubDate>Tue, 23 Jun 2026 15:49:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/38787e16-7e86-44da-9a6a-620cc1a99fce.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Typically, when we start experimenting with AI, many of us begin similarly. We try a single LLM call as the core of an app, like this:</p>
<pre><code class="language-plaintext">const response = await llm.chat("Explain Kubernetes");
</code></pre>
<p>For a little while it feels like the whole flow is: the user asks something, and the model returns an answer. That early success often creates a false impression that building AI is just about sending prompts and getting responses.</p>
<p>That simplicity is seductive, but it doesn't hold up. Over time, users want the assistant to find answers in their documents and knowledge bases, call APIs, fetch live data, or trigger services or schedule meetings.</p>
<p>Users also expect the agent to access internal systems and interact with ERPs, CRMs, or other tools holding critical business data. They'll want agents to combine multiple steps, as workflows often require chaining queries, computations, and side effects into reliable processes.</p>
<p>This is where concepts like MCP (the Model Context Protocol) and tools like LangChain come in. Initially, they may seem like buzzwords, but they address different aspects of LLM production.</p>
<p>After experimenting with AI tools, I found that these concepts help solve different problems related to interfaces, orchestration, and system integration.</p>
<p>This article is a practical guide to understanding how LLMs connect with tools, orchestrate workflows, and power real AI applications.</p>
<h3 id="heading-heres-what-well-cover">Here’s what we’ll cover:</h3>
<ol>
<li><p><a href="#heading-what-is-an-llm">What Is an LLM?</a></p>
</li>
<li><p><a href="#heading-why-llms-need-tools">Why LLMs Need Tools</a></p>
</li>
<li><p><a href="#heading-where-mcp-comes-in">Where MCP Comes In</a></p>
</li>
<li><p><a href="#heading-so-what-does-langchain-actually-do">So What Does LangChain Actually Do?</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting It Together</a></p>
</li>
<li><p><a href="#heading-what-i-built-while-learning-this">What I Built While Learning This</a></p>
</li>
</ol>
<p>Throughout the article we'll discuss what LLMs are and how they work, what tool-calling looks like in practice, what MCP is and how it works, how LangChain fits into the whole process, and how to put all these tools together.</p>
<p>To follow along, you'll need a basic understanding of Node.js, API operations, and basic JavaScript concepts.</p>
<h2 id="heading-what-is-an-llm"><strong>What Is an LLM?</strong></h2>
<p>LLM stands for <strong>Large Language Model</strong>. It's a class of deep neural networks trained on massive amounts of text to model and generate human-like language. Popular examples you might have heard of include GPT, Claude, Gemini, and Llama.</p>
<h3 id="heading-how-to-call-an-llm-from-a-nodejs-application">How to Call an LLM From a Node.js Application</h3>
<p>Before writing code, let’s understand what it means to call an LLM from a Node.js application.</p>
<p>Calling an LLM means sending input from your application to an AI provider’s API and receiving generated output in return. It's similar to calling any other external service.</p>
<p>In most real-world applications, the model isn't hosted or trained by your application. Instead, providers such as OpenAI and Groq host and maintain the models, while your application communicates with them over HTTP APIs.</p>
<p>In this example, we’ll build a minimal API using Node.js and Express. We’ll create a simple <code>POST /chat</code> endpoint that accepts a user message, sends it to the OpenAI API, receives the generated response, and returns it to the client.</p>
<p>Here, our Node.js server acts as the bridge between the user and the LLM provider.</p>
<p>For this example, create an API key from the <a href="https://console.groq.com/keys">Groq</a> console. Since it offers a free tier, it’s a simple way to experiment and understand the concepts.</p>
<p>First, install the dependencies:</p>
<pre><code class="language-plaintext">npm install express
</code></pre>
<pre><code class="language-javascript">import express from "express";

const app = express();
app.use(express.json());

app.post("/chat", async (req, res) =&gt; {
  const { message } = req.body;
  const response = await fetch("https://api.groq.com/openai/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: GROQ_API_KEY,
    },
    body: JSON.stringify({
      model: "llama-3.3-70b-versatile",
      messages: [{ role: "user", content: message }],
    }),
  });

  const data = await response.json();

  if (!response.ok) {
    return res.status(response.status).json({ error: data });
  }

  const reply = data.choices[0].message.content;

  res.json({ reply });
});

const PORT = process.env.PORT || 8888;
app.listen(PORT, () =&gt; {
  console.log(`Server running on http://localhost:${PORT}`);
});
</code></pre>
<p>Start the server and make a request. Use Postman and do a POST request to <code>/chat</code> using the below body:</p>
<pre><code class="language-plaintext">POST /chat

{
  "message": "Explain Kubernetes"
}
</code></pre>
<p>Example response:</p>
<pre><code class="language-plaintext">{
  "reply": "Kubernetes is a container orchestration platform..."
}
</code></pre>
<p>The backend receives the message, forwards it to the model provider, receives generated text, and returns it to the client.</p>
<p>LLMs are excellent at language-centric tasks: they understand phrasing and intent, generate coherent text, extract structured information from unstructured input, and perform basic reasoning over provided context. These capabilities make them powerful for things like summarization, drafting, and conversational QA.</p>
<p>But there’s an important limitation: LLMs don't automatically know about and can't access your private or live data. They don’t have implicit access to your company database, internal APIs, or the current state of your systems unless you provide that information at runtime.</p>
<p>Because of that limitation, you need secure mechanisms to connect models to live systems and data — which brings us to the idea of tools.</p>
<h2 id="heading-why-llms-need-tools"><strong>Why LLMs Need Tools</strong></h2>
<p>Imagine asking:</p>
<blockquote>
<p>Check my order and raise support if delivery is delayed.</p>
</blockquote>
<p>The model alone can't inspect your order database or create a support ticket in your system. To do that, it must call external functions — for example, a <code>getOrderStatus(orderId)</code> API and a <code>createSupportTicket(orderId, issue)</code> action.</p>
<p>Those callable functions are what we call tools: programmatic interfaces the AI can use to interact with systems and take concrete actions on behalf of users.</p>
<p>A tool is simply a function that an AI model can call to interact with external systems or perform actions.</p>
<p>For example, imagine we have a getOrderStatus(id) function that returns an order’s delivery status.</p>
<p>To expose this to the LLM, we define a tools array. Each tool includes:</p>
<ul>
<li><p>type – currently "function"</p>
</li>
<li><p>function name – the function identifier</p>
</li>
<li><p>function description – helps the LLM decide when to call the tool</p>
</li>
<li><p>function parameters – a JSON Schema describing the arguments the tool expects</p>
</li>
</ul>
<p>Here's an example:</p>
<pre><code class="language-typescript">function getOrderStatus(id) {
  const statuses = ["pending", "success", "cancelled"];
  const status = statuses[Math.floor(Math.random() * statuses.length)];
  return `Your order status is ${status}.`;
}

const tools = [
  {
    type: "function",
    function: {
      name: "getOrderStatus",
      description: "Get the status of an order by its ID",
      parameters: {
        type: "object",
        properties: {
          id: { type: "string", description: "The order ID" },
        },
        required: ["id"],
      },
    },
  },
];
</code></pre>
<p>The above tool format is for Grok. Different LLM providers may use different formats for defining tools, but the overall idea remains the same.</p>
<p>When making the API call, we pass both the user messages and the list of available tools.</p>
<pre><code class="language-typescript">body: JSON.stringify({
    model: "llama-3.3-70b-versatile",
    messages: [{ role: "user", content: message }],
    tools,
}),
</code></pre>
<p>After the API call, the LLM decides whether a tool is needed. If a tool call is requested, our application executes the corresponding function and sends the result back to the model.</p>
<p>For this example, we'll only handle the <code>getOrderStatus</code> tool. We can check whether the model requested a tool call like this:</p>
<pre><code class="language-typescript">const toolCall = data.choices[0].message.tool_calls[0];
const { id } = JSON.parse(toolCall.function.arguments);
const toolResult = getOrderStatus(id)
</code></pre>
<p>and later we can pass the message context with tool result</p>
<pre><code class="language-typescript">body: JSON.stringify({
    model: "llama-3.3-70b-versatile",
    messages: [
        { role: "user", content: message },
        assistantMessage,
        { role: "tool", tool_call_id: toolCall.id, content: toolResult },
    ],
    tools,
}),
</code></pre>
<p>Finally, return the response:</p>
<pre><code class="language-typescript">return res.json({ reply: followUpData.choices[0].message.content });
</code></pre>
<p>Here's a diagram of the flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a1fa5fdc5c3ae375fb38ab2/22d6dc4d-ad5e-4fbb-84f6-71c367565282.png" alt="User -> LLM -> Tool Execution -> Tool Result -> Final Response" style="display: block;" width="1774" height="887" loading="lazy">

<p>The LLM decides whether a tool is needed and generates the required inputs, while your application executes the function.</p>
<h2 id="heading-where-mcp-comes-in"><strong>Where MCP Comes In</strong></h2>
<p>Tools are simple. You define functions and tell the AI what it can use.</p>
<p>For example, <code>getOrderStatus()</code> works well when all tools are built inside your application. But as applications grow, tools may come from many places, like Slack, GitHub, databases, internal systems, or third-party services. Each one may expose tools differently.</p>
<p>This is where <a href="https://www.freecodecamp.org/news/how-does-an-mcp-work-under-the-hood/">MCP (Model Context Protocol) helps</a>. Think of MCP as a common language that lets AI systems connect to external tools in a consistent way.</p>
<p>Tools define what the AI can do. MCP standardizes how the AI connects to and uses those tools.</p>
<p>Now let’s extend the previous /chat API example so the LLM can use tools exposed through MCP. There are multiple ways to do this:</p>
<ul>
<li><p>build and host your own MCP server and expose your application functions</p>
</li>
<li><p>connect to existing third-party MCP servers such as Slack</p>
</li>
</ul>
<p>For this tutorial, we'll keep things simple and use a remote MCP server approach because it's easier to understand.</p>
<pre><code class="language-plaintext">npm install express @modelcontextprotocol/sdk zod
</code></pre>
<p>Now let’s create our own MCP server and expose the same <code>getOrderStatus</code> function as an MCP tool:</p>
<pre><code class="language-typescript">import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

function getOrderStatus(id) {
  const statuses = ["pending", "success", "cancelled"];
  const status = statuses[Math.floor(Math.random() * statuses.length)];
  return `Your order status is ${status}.`;
}

function createOrderServer() {
  const server = new McpServer({ name: "order-server", version: "1.0.0" });

  server.registerTool(
    "getOrderStatus",
    {
      description: "Get the status of an order by its ID",
      inputSchema: { id: z.string() },
    },
    async ({ id }) =&gt; ({
      content: [{ type: "text", text: getOrderStatus(id) }],
    })
  );

  return server;
}

const app = createMcpExpressApp({ host: "0.0.0.0" });

app.post("/mcp", async (req, res) =&gt; {
  const server = createOrderServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  res.on("close", () =&gt; {
    transport.close();
    server.close();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, "0.0.0.0", () =&gt; {
  console.log(`Order MCP server running on http://0.0.0.0:${PORT}/mcp`);
});
</code></pre>
<p>This is useful when you want to expose your own application functions through MCP. Typically, the MCP server runs separately and is accessed by MCP clients. Now any MCP client can connect to this server and discover the available tools automatically.</p>
<p>The same idea applies to third-party MCP servers.</p>
<p>For example, if a Slack MCP server is available, we can connect to it instead of writing Slack integration code ourselves.</p>
<p>In that case, our application isn't directly calling Slack APIs. It connects to the Slack MCP server, which exposes Slack-related tools using the MCP standard.</p>
<p>So the difference is:</p>
<ul>
<li><p>For our own features, we can build our own MCP server</p>
</li>
<li><p>For external systems, we can use existing MCP servers when available</p>
</li>
</ul>
<p>Now we can pass MCP servers to the LLM request:</p>
<pre><code class="language-typescript">body: JSON.stringify({
  model: "llama-3.3-70b-versatile",
  messages: [{ role: "user", content: message }],
  tools: [
    {
      type: "mcp",
      server_label: "OrderServer",
      server_url: `http://0.0.0.0:${PORT}/mcp`,
      server_description: "Get the status of an order by its ID",
    },
    {
      type: "mcp",
      server_label: "Slack",
      server_url: "https://mcp.slack.com/mcp",
      server_description: "Send and read Slack messages",
      headers: {
        Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
      },
    },
  ],
})
</code></pre>
<p>We can also use local MCP servers instead of remote URLs by connecting through transports such as <code>StdioClientTransport</code>. In that case, we connect locally, discover the available tools, and expose them to the LLM.</p>
<p>Now if the user sends:</p>
<pre><code class="language-json">{
  "message": "What is status of order 123"
}
</code></pre>
<p>The LLM decides whether a tool is needed, MCP exposes and executes the tool, and the final response is returned to the user.</p>
<p>The flow becomes:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a1fa5fdc5c3ae375fb38ab2/2db75d86-db9a-477e-b578-92221a490a2a.png" alt="User -> /chat api -> LLM -> MCP Tool -> Tool Result -> Tool Response" style="display: block;" width="1774" height="887" loading="lazy">

<p>This standardization makes integrations far more reusable: instead of rewriting glue logic for each new connector, teams can register MCP-compliant tools and let the orchestrator and model handle discovery and invocation.</p>
<h2 id="heading-so-what-does-langchain-actually-do"><strong>So What Does LangChain Actually Do?</strong></h2>
<p>I initially thought LangChain was simply another wrapper around LLM APIs, but it is better understood as an orchestration framework for AI workflows. Tools let an LLM perform actions. MCP standardizes how tools are exposed. LangChain helps coordinate models, tools, and application logic to build multi-step workflows.</p>
<p>For example:</p>
<blockquote>
<p>User: Find flights, compare prices, book hotel, send confirmation.</p>
</blockquote>
<p>Now the system may need to:</p>
<ul>
<li><p>Check order status</p>
</li>
<li><p>Decide whether support is needed</p>
</li>
<li><p>Create a support ticket</p>
</li>
<li><p>Generate the final response</p>
</li>
</ul>
<p>Without orchestration, you would manually control each step. LangChain helps manage this flow.</p>
<p>To use LangChain, Install the required packages:</p>
<pre><code class="language-json">npm install express langchain @langchain/groq
</code></pre>
<p>We'll reuse the same tool functions from earlier:</p>
<pre><code class="language-typescript">import express from "express";
import { createAgent } from "langchain";
import { ChatGroq } from "@langchain/groq";

const app = express();
app.use(express.json());

const agent = createAgent({
  model: new ChatGroq({
    model: "llama-3.3-70b-versatile",
    apiKey: GROQ_API_KEY,
  }),
  tools: [
    {
      name: "getOrderStatus",
      description:
        "Get order status",
      execute: ({ id }) =&gt;
        getOrderStatus(id), // we have this function above
    },
    {
      name: "createSupportTicket",
      description:
        "Create support ticket",
      execute: ({ id }) =&gt;
        createSupportTicket(id), //imagine a function that creates a support ticket
    },
  ],
});

app.post(
  "/chat",
  async (req, res) =&gt; {
    const { message } = req.body;

    const response =
      await agent.invoke({
        messages: [
          {
            role: "user",
            content: message,
          },
        ],
      });

    res.json({
      reply:
        response.messages
          ?.at(-1)
          ?.text,
    });
  }
);

app.listen(3000);
</code></pre>
<p>Now the flow becomes:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a1fa5fdc5c3ae375fb38ab2/bd2a266c-39eb-4f3e-9909-ad81360bccb7.png" alt="Horizontal architecture diagram showing User → /chat API → LangChain Agent → OpenAI → Tool → Tool Result → Final Response." style="display: block;" width="1930" height="815" loading="lazy">

<p>LangChain doesn't replace tools or MCP. It sits above them and coordinates how everything works together.</p>
<h2 id="heading-putting-it-together"><strong>Putting It Together</strong></h2>
<p>A modern AI application usually has multiple layers working together. The LLM handles reasoning and language generation. Tools perform real operations such as reading data, calling APIs, or executing actions. MCP helps standardize how those tools are exposed and accessed. LangChain helps orchestrate the interaction between models, tools, and workflows.</p>
<p>By separating these responsibilities, applications become easier to extend, maintain, and scale.</p>
<p>The goal is more than just generating text. You want to be able to build systems that can reason, retrieve information, take actions, and reliably solve real user problems.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a1fa5fdc5c3ae375fb38ab2/bfc88660-3145-4b89-a626-158c4ec52bcc.png" alt="User ->LLM -> LangChain -> MCP -> Tools -> Systems &amp; Data" style="display: block;" width="1536" height="1024" loading="lazy">

<h2 id="heading-what-i-built-while-learning-this"><strong>What I Built While Learning This</strong></h2>
<p>After understanding the concepts above, I wanted to reduce some of this setup for my own projects. As I experimented, I noticed most applications recreate the same plumbing over and over: connecting an LLM, wiring up tools, managing execution, and exposing orchestration patterns.</p>
<p>So I built a small open-source toolkit to reduce that setup. The goal was simple: you should be able to focus on business logic instead of wiring AI infrastructure.</p>
<p>Current capabilities:</p>
<ul>
<li><p>LLM integration</p>
</li>
<li><p>Tool registration</p>
</li>
<li><p>Tool execution</p>
</li>
<li><p>Chat orchestration</p>
</li>
<li><p>LangChain support</p>
</li>
<li><p>Extensible architecture</p>
</li>
</ul>
<h3 id="heading-packages">Packages:</h3>
<p>AI Chat Widget: <a href="https://www.npmjs.com/package/ai-chat-toolkit-widget">https://www.npmjs.com/package/ai-chat-toolkit-widget</a></p>
<p>AI Chat Server: <a href="https://www.npmjs.com/package/ai-chat-toolkit-server">https://www.npmjs.com/package/ai-chat-toolkit-server</a></p>
<p>GitHub Repository: <a href="https://github.com/sudheeshshetty/ai-chat-toolkit">https://github.com/sudheeshshetty/ai-chat-toolkit</a></p>
<p>To build a server using the toolkit:</p>
<pre><code class="language-typescript">npm install express ai-chat-toolkit-server
</code></pre>
<p>Create the chat server:</p>
<pre><code class="language-typescript">const aiChat = new AiChatServer({
  path: "/my-chat",
  provider: "groq",
  apiKey: process.env.API_KEY,
  model: process.env.MODEL || "llama-3.3-70b-versatile",
  cors: {
    origin: "http://localhost:5174",
  },
  orchestration: "langchain",
  maxToolRounds: 6,
  systemPrompt:
    "You are a helpful operations assistant for a demo store. Keep answers concise.",
});
</code></pre>
<p>Add your tools:</p>
<pre><code class="language-typescript">aiChat.addTools([
  {
    name: "...",
    description: "...",
    inputSchema: { ... },
    handler: async (input) =&gt; { /* runs in Node */ },
  },
]);
</code></pre>
<p>Attach it to your Express app:</p>
<pre><code class="language-typescript">aiChat.attach(app);
</code></pre>
<p>Now <code>/my-chat</code> is exposed in your Express server and can be used directly.</p>
<p>You can also use <code>ai-chat-toolkit-widget</code> if you want to skip building the chat UI.</p>
<p>Examples are available in the repository, so you can try it out quickly.</p>
<p>A quick glance of one of the examples:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a1fa5fdc5c3ae375fb38ab2/a9079710-be65-472b-881f-350daeeb0f3b.gif" alt="a9079710-be65-472b-881f-350daeeb0f3b" style="display: block;" width="3456" height="2234" loading="lazy">

<p>If you find it useful, I’d appreciate a star, feedback, or contributions on GitHub as I continue improving the developer experience and exploring new ideas.<br>Thanks for reading — I hope this helped make LLMs, tools, MCP, and LangChain feel a little less magical and a lot more practical.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Open Source Tools Every STEM Student Should Know About ]]>
                </title>
                <description>
                    <![CDATA[ Technology has changed the way students learn science, mathematics, engineering, and computer science. A decade ago, most STEM students depended on textbooks, calculators, and expensive licensed softw ]]>
                </description>
                <link>https://www.freecodecamp.org/news/open-source-tools-every-stem-student-should-know-about/</link>
                <guid isPermaLink="false">6a27af485df8cf4edcb24d9b</guid>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stem ]]>
                    </category>
                
                    <category>
                        <![CDATA[ student ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Computer Science ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Tue, 09 Jun 2026 06:14:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0909758a-68d8-4064-9216-73838a1d9f88.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Technology has changed the way students learn science, mathematics, engineering, and computer science.</p>
<p>A decade ago, most STEM students depended on textbooks, calculators, and expensive licensed software. Today, open source tools have made advanced learning resources available to anyone with an internet connection.</p>
<p>Many of these tools are powerful enough for professional researchers and software engineers, yet simple enough for students who are just getting started. They help with coding, data analysis, mathematics, technical writing, visualization, collaboration, and project management.</p>
<p>In this article, we'll look at seven open source tools that can help STEM students study more effectively, build projects faster, and develop industry-ready technical skills.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-why-open-source-tools-matter-for-stem-students">Why Open Source Tools Matter for STEM Students</a></p>
</li>
<li><p><a href="#heading-jupyter-notebook-for-interactive-learning">Jupyter Notebook for Interactive Learning</a></p>
</li>
<li><p><a href="#heading-vs-code-for-programming-and-technical-projects">VS Code for Programming and Technical Projects</a></p>
</li>
<li><p><a href="#heading-geogebra-for-mathematics-visualization">GeoGebra for Mathematics Visualization</a></p>
</li>
<li><p><a href="#heading-git-and-github-for-collaboration">Git and GitHub for Collaboration</a></p>
</li>
<li><p><a href="#heading-blender-for-scientific-and-engineering-visualization">Blender for Scientific and Engineering Visualization</a></p>
</li>
<li><p><a href="#heading-obs-studio-for-recording-and-presentations">OBS Studio for Recording and Presentations</a></p>
</li>
<li><p><a href="#heading-how-open-source-tools-build-career-skills">How Open Source Tools Build Career Skills</a></p>
</li>
<li><p><a href="#heading-the-future-of-stem-education">The Future of STEM Education</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-open-source-tools-matter-for-stem-students"><strong>Why Open Source Tools Matter for STEM Students</strong></h2>
<p>Open source software is more than just free software. It gives students access to the underlying code, community support, and the freedom to experiment without restrictions.</p>
<p>This matters because STEM education is becoming increasingly hands-on. Employers expect students to understand practical workflows, not just theory. Learning how to use modern tools early can make the transition into internships and engineering roles much easier.</p>
<p>Open source ecosystems also evolve quickly. Students can explore real-world technologies used in research labs, startups, and large engineering organizations. Many of these environments also rely on <a href="https://www.pulseofstrategy.com/best-n8n-alternatives/">open-source automation</a> tools to simplify development workflows and improve collaboration across technical teams.</p>
<h2 id="heading-jupyter-notebook-for-interactive-learning"><strong>Jupyter Notebook for Interactive Learning</strong></h2>
<p>One of the most important tools for STEM students is <a href="https://jupyter.org/">Jupyter Notebook</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/24cdd6b3-ea00-4d93-b71d-73f7b3e2e1a6.png" alt="Jupyter Notebook" style="display: block;" width="1686" height="1114" loading="lazy">

<p>Jupyter Notebook allows users to combine code, mathematical equations, visualizations, and notes inside a single interactive document. This makes it extremely useful for subjects like data science, physics, statistics, and machine learning.</p>
<p>A student can write Python code, run calculations, and immediately visualize the output using graphs or tables. Instead of switching between multiple applications, everything exists in one place.</p>
<p>For example, a physics student can simulate motion equations, while a statistics student can analyze datasets directly inside the notebook.</p>
<p>Jupyter is widely used in universities and research institutions because it supports experimentation and iterative learning.</p>
<h2 id="heading-vs-code-for-programming-and-technical-projects"><strong>VS Code for Programming and Technical Projects</strong></h2>
<p><a href="https://code.visualstudio.com/">Visual Studio Code</a> has become one of the most popular development environments in the world. Although it is developed by Microsoft, it's built on open source technologies and supports a massive extension ecosystem.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/85de174e-0aba-439f-9820-8a463dc4a5da.png" alt="VS Code" style="display: block;" width="1201" height="669" loading="lazy">

<p>For STEM students, VS Code is valuable because it supports nearly every major programming language. Whether you're learning Python, JavaScript, C++, or Rust, the editor provides debugging, syntax highlighting, terminal integration, and Git support in one interface.</p>
<p>Engineering students often work across multiple disciplines. A robotics student might write Python scripts, configure embedded systems, and document experiments all in the same environment.</p>
<p>VS Code also integrates well with Jupyter Notebook, making it an excellent all-in-one workspace for technical learning.</p>
<h2 id="heading-geogebra-for-mathematics-visualization"><strong>GeoGebra for Mathematics Visualization</strong></h2>
<p>Mathematics becomes easier when students can visualize concepts instead of memorizing formulas.</p>
<p><a href="https://www.geogebra.org/">GeoGebra</a> is an open source mathematics platform that helps students explore algebra, geometry, calculus, and statistics through interactive graphs and simulations.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/a2623d2c-6226-4b63-9040-adca131acc6a.png" alt="GeoGebra" style="display: block;" width="1363" height="649" loading="lazy">

<p>Students can manipulate equations dynamically and observe how graphs change in real time. This creates a much deeper understanding of mathematical relationships.</p>
<p>Interactive visualisation tools are especially useful for students preparing for advanced mathematics courses. Popular teaching platforms like <a href="https://brighterly.com/">Brighterly</a> who are known as a great precalculus tutor, use graphing platforms like GeoGebra to better understand trigonometric functions, transformations, and polynomial behaviour. The platform is also useful for individual teachers who want to create interactive lessons instead of relying entirely on static diagrams.</p>
<h2 id="heading-git-and-github-for-collaboration"><strong>Git and GitHub for Collaboration</strong></h2>
<p>Version control is one of the most important technical skills students can learn.</p>
<p><a href="https://git-scm.com/">Git</a> is an open source version control system that helps developers track changes in code and collaborate efficiently. It is widely used across software engineering, data science, and research projects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/44199e64-6660-4a37-80bf-f87e9fe466da.webp" alt="Github" style="display: block;" width="1914" height="1314" loading="lazy">

<p>Students often lose work because they overwrite files or create confusing project versions. Git solves this problem by maintaining a complete history of changes.</p>
<p>When paired with <a href="https://github.com/">GitHub</a>, students can collaborate on projects, contribute to open source repositories, and build a public portfolio of technical work.</p>
<p>This is especially valuable for computer science students applying for internships or engineering roles. Recruiters frequently review GitHub profiles to evaluate coding ability and project experience.</p>
<p>Even students outside traditional software engineering fields benefit from Git. Researchers use it for reproducible experiments, while engineering teams use it to manage technical documentation and simulation code.</p>
<h2 id="heading-blender-for-scientific-and-engineering-visualization"><strong>Blender for Scientific and Engineering Visualization</strong></h2>
<p>Most people associate Blender with animation and game design, but it's also a powerful tool for STEM applications.</p>
<p><a href="https://www.blender.org/">Blender</a> is an open source 3D modeling and rendering platform used in industries ranging from architecture to scientific visualization.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/14dfc5d6-9ff6-4934-9220-aa027abd8a64.png" alt="Blender" style="display: block;" width="1600" height="957" loading="lazy">

<p>Engineering students can use Blender to create product prototypes, mechanical visualizations, and simulation renders. Biology students can build anatomical models, while physics students can visualize complex systems in three dimensions.</p>
<p>Visualization plays a major role in technical understanding. A well-designed 3D model can explain concepts that are difficult to communicate through text alone.</p>
<p>Blender also teaches valuable spatial reasoning and design skills that are increasingly useful in fields like robotics, manufacturing, and augmented reality.</p>
<h2 id="heading-obs-studio-for-recording-and-presentations"><strong>OBS Studio for Recording and Presentations</strong></h2>
<p>Modern STEM learning is becoming more collaborative and content-driven.</p>
<p>Students now create tutorials, record presentations, explain coding projects, and participate in online learning communities. <a href="https://obsproject.com/">OBS Studio</a> is an open source tool that allows users to record screens, stream presentations, and create technical demonstrations.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/be764693-ba75-4103-a071-69ebd745b91c.jpg" alt="OBS Studio" style="display: block;" width="1920" height="1080" loading="lazy">

<p>This is particularly useful for students building portfolios or preparing project walkthroughs.</p>
<p>For example, a software engineering student can record a demo of a web application, while a mathematics student can create video explanations of problem-solving methods.</p>
<p>OBS Studio is lightweight, flexible, and widely used by educators, developers, and technical creators.</p>
<h2 id="heading-how-open-source-tools-build-career-skills"><strong>How Open Source Tools Build Career Skills</strong></h2>
<p>One of the biggest advantages of open source tools is that they mirror real industry workflows.</p>
<p>Students aren't just learning academic concepts. They're learning systems used in professional engineering environments.</p>
<p>A student who understands Git, VS Code, Jupyter, and collaborative development practices already has exposure to modern software engineering workflows. Similarly, students using Blender or GeoGebra are developing visualization and analytical skills that transfer into technical careers.</p>
<p>Open source communities also encourage experimentation. Students can inspect source code, contribute fixes, participate in discussions, and learn directly from experienced developers around the world.</p>
<p>This creates a more active learning process than simply consuming tutorials.</p>
<h2 id="heading-the-future-of-stem-education"><strong>The Future of STEM Education</strong></h2>
<p>STEM education is shifting toward project-based and interdisciplinary learning.</p>
<p>Students are expected to solve problems, communicate ideas clearly, and adapt to rapidly evolving technologies. Open source tools make this possible by lowering financial barriers and giving students access to professional-grade software.</p>
<p>The rise of artificial intelligence, data science, and remote collaboration has also increased the importance of technical self-learning. Students who can independently explore tools and build projects will have a significant advantage in both academics and industry.</p>
<p>The good news is that modern open source ecosystems make this easier than ever before. A student with a laptop and internet connection can now access tools that were once available only to large universities or research organizations.</p>
<h2 id="heading-final-thoughts"><strong>Final Thoughts</strong></h2>
<p>The best STEM students aren't always the ones with the most expensive hardware or software. Often, they're the ones who learn how to use accessible tools creatively and consistently.</p>
<p>Platforms like Jupyter Notebook, VS Code, GeoGebra, LibreOffice, Git, Blender, and OBS Studio provide a strong foundation for technical learning across many disciplines.</p>
<p>More importantly, these tools encourage curiosity, experimentation, and practical problem-solving. Those skills matter far beyond the classroom.</p>
<p>As STEM education continues to evolve, students who embrace open source technology will be better prepared for research, engineering, software development, and the increasingly interdisciplinary future of technical work.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Connect Your AI Coding Agent to a Browser on macOS  ]]>
                </title>
                <description>
                    <![CDATA[ AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to look at something on the web, they hit a wall. They can't s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/</link>
                <guid isPermaLink="false">6a1594c1da253d50d4ae1277</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ macOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ אחיה כהן ]]>
                </dc:creator>
                <pubDate>Tue, 26 May 2026 12:40:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/7e77f1c5-6942-4dbe-a3c6-ca74cc4354e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to <em>look at something on the web</em>, they hit a wall. They can't see your staging site. They can't read the error in your analytics dashboard. They can't check whether the form they just built actually submits.</p>
<p>The usual fix is to hand the agent a headless browser — Puppeteer or Playwright driving a fresh Chromium instance. That works, sort of. But a headless Chromium starts every session as a stranger: no logins, no cookies, no sessions. It spins up a second browser engine that pushes your CPU and spins up your fan. And a growing number of sites simply block it on sight.</p>
<p>There's another option, and on a Mac it's a good one: let the agent drive the <strong>Safari you already use</strong> — the one that's already logged into GitHub, your analytics, your staging environment. That's what Safari MCP does. It's an open-source MCP server that exposes Safari to any MCP-capable agent through around 80 tools, with no Chromium, no WebDriver, and no separate browser to babysit.</p>
<p>In this tutorial you'll connect Safari MCP to an AI agent, run your first automation, and then build something a headless browser fundamentally cannot do: an automation that works inside a page you're logged into. By the end you'll understand not just <em>how</em> to wire this up, but <em>when</em> native browser automation is the right call — and when it isn't.</p>
<p>Here's what you'll need:</p>
<ul>
<li><p>A Mac (Safari MCP is macOS-only — more on that trade-off later)</p>
</li>
<li><p>Node.js 18 or newer</p>
</li>
<li><p>An MCP-capable AI agent — this tutorial uses Claude Code and Cursor, but any MCP client works</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</a></p>
</li>
<li><p><a href="#heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</a></p>
</li>
<li><p><a href="#heading-installing-safari-mcp">Installing Safari MCP</a></p>
</li>
<li><p><a href="#heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</a></p>
</li>
<li><p><a href="#heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</a></p>
</li>
<li><p><a href="#heading-handling-the-tricky-parts">Handling the Tricky Parts</a></p>
</li>
<li><p><a href="#heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</h2>
<p>Before wiring anything up, it helps to know what the "MCP" in Safari MCP stands for.</p>
<p><strong>MCP</strong> is the Model Context Protocol — an open standard for connecting AI agents to external tools and data. Think of it the way you'd think of a USB port. Before USB, every device needed its own connector. MCP is the equivalent of agreeing on one connector: an agent that speaks MCP can use <em>any</em> tool that speaks MCP, with no custom integration code on either side.</p>
<p>An MCP <strong>server</strong> exposes a set of tools. An MCP <strong>client</strong> — your AI agent — discovers those tools and calls them. The server describes each tool (its name, what it does, what arguments it takes) and the agent decides when to call it. When Claude Code decides it needs to read a web page, it doesn't run browser code itself. It calls a tool that some MCP server provides.</p>
<p>Browser automation is a natural fit for this model. The agent's job is reasoning — "I need to see what's on the staging site, then check the console for errors." The actual mechanics — open a tab, wait for load, read the DOM, capture console output — are well-defined operations that belong behind a stable interface. That interface is exactly what an MCP server provides.</p>
<p>Safari MCP is one such server. It runs as a local process, exposes around 80 browser tools (navigate, click, fill, read, screenshot, extract, and more), and any MCP client can drive it. The agent never touches AppleScript or WebKit internals. It just calls <code>safari_navigate</code> and gets a result.</p>
<p>The "USB port" framing matters for a practical reason: nothing in this tutorial is Claude-specific. Wire Safari MCP into Cursor, Cline, Windsurf, or your own MCP client and the tools are identical.</p>
<h2 id="heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</h2>
<p>If you've automated a browser before, you've almost certainly used Chrome through Puppeteer, Playwright, or Selenium. So why reach for Safari?</p>
<p>It comes down to three differences that matter once an <em>AI agent</em>, not a test script, is the thing driving the browser.</p>
<p><strong>1. It's your real browser, with your real sessions.</strong> A headless Chromium launched by Playwright is a clean room. It has never logged into anything. If you want your agent to read your analytics dashboard, you first have to solve authentication — store credentials somewhere, script the login, handle two-factor prompts, refresh tokens. Safari MCP skips all of that. It drives the Safari instance you use every day, which is <em>already</em> logged into your dashboards, your GitHub, your email. The agent inherits those sessions for free.</p>
<p><strong>2. It doesn't melt your laptop.</strong> A headless Chromium is a second, full browser engine running alongside the browser you already have open. On a laptop that's real CPU, real memory, and a fan you can hear. Safari MCP uses the WebKit engine that's already running on every Mac — there's no second engine to start. The project measures this at roughly 60% less CPU for the browsing work, and the automation runs with Safari in the background, so it doesn't steal your screen.</p>
<p><strong>3. Sites don't treat it as a bot.</strong> Headless browsers leak. They expose <code>navigator.webdriver</code>, they ship with telltale automation fingerprints, and bot-detection services — Cloudflare's challenge pages, reCAPTCHA, the WAFs in front of a lot of B2B sites — have gotten very good at spotting them. Your real Safari, driven through the operating system, looks like exactly what it is: a person's browser. (To be clear: this is for automating <em>your own</em> accounts and sites — not for evading access controls you don't own.)</p>
<p>The cost of all this is the obvious one: <strong>Safari MCP is macOS-only.</strong> It's built on WebKit and AppleScript, so there's no Windows or Linux story. If your agent runs on a Linux CI box, this isn't your tool. If it runs on your Mac — which, for a coding agent, it very often does — the trade is a good one. We'll come back to limitations honestly at the end.</p>
<h2 id="heading-installing-safari-mcp">Installing Safari MCP</h2>
<p>Installation is genuinely one command, but there are two Safari settings to flip first. Let's do it in order.</p>
<h3 id="heading-step-1-enable-safaris-developer-features">Step 1 — Enable Safari's developer features</h3>
<p>Safari MCP reads and controls pages by running JavaScript inside Safari. Two settings have to be on:</p>
<ol>
<li><p>Open <strong>Safari → Settings → Advanced</strong> and check <strong>"Show features for web developers."</strong> This reveals the Develop menu.</p>
</li>
<li><p>Open the new <strong>Develop</strong> menu and check <strong>"Allow JavaScript from Apple Events."</strong></p>
</li>
</ol>
<p>That second one is the important one. It's what lets an outside process — the MCP server — ask Safari to run JavaScript on a page. Without it, every tool call fails.</p>
<h3 id="heading-step-2-run-the-server">Step 2 — Run the server</h3>
<pre><code class="language-bash">npx safari-mcp
</code></pre>
<p>That's the whole install. <code>npx</code> fetches the package and runs it; there's nothing to build. The first time an agent calls a tool, macOS will pop up a permission prompt — something like <em>"Terminal wants to control Safari."</em> Click <strong>OK</strong>. That's the standard Automation permission, and you can review it later under <strong>System Settings → Privacy &amp; Security → Automation</strong>.</p>
<p>If you'd rather have it installed permanently:</p>
<pre><code class="language-bash">npm install -g safari-mcp
</code></pre>
<h3 id="heading-step-3-tell-your-agent-about-it">Step 3 — Tell your agent about it</h3>
<p>Your AI agent needs to know the server exists. For <strong>Claude Code</strong>, one command does it:</p>
<pre><code class="language-bash">claude mcp add safari -- npx safari-mcp
</code></pre>
<p>For <strong>Cursor</strong>, create <code>.cursor/mcp.json</code> in your project:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "safari": {
      "command": "npx",
      "args": ["safari-mcp"]
    }
  }
}
</code></pre>
<p>The process is the same for every client — Claude Desktop, Cline, Windsurf, Continue, VS Code. You're telling the agent: "there's an MCP server named <code>safari</code>; start it by running <code>npx safari-mcp</code>."</p>
<p>Restart your agent (or reload its MCP servers) and it will connect. In Claude Code you can confirm with the <code>/mcp</code> command, which lists connected servers and their tools. You should see <code>safari</code> with around 80 tools available.</p>
<p>That's it. Your agent now has a browser.</p>
<h2 id="heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</h2>
<p>Let's prove the wiring works with the simplest possible task: have the agent read a web page.</p>
<p>In your agent, just ask in plain language:</p>
<blockquote>
<p>"Use the safari tools to open example.com and tell me what the page says."</p>
</blockquote>
<p>Behind that request, the agent makes two tool calls. First it navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://example.com" } }
</code></pre>
<p>Then it reads the content:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": {} }
</code></pre>
<p><code>safari_read_page</code> returns the page's title, URL, and text content with the HTML stripped out — exactly the form an LLM wants. The agent gets back something like this:</p>
<pre><code class="language-plaintext">Example Domain
https://example.com/
This domain is for use in illustrative examples in documents. You may
use this domain in literature without prior coordination or asking for
permission.
</code></pre>
<p>And it relays that to you. You just watched your agent browse.</p>
<p>A quick note on <em>how</em> the agent should look at a page, because it changes everything downstream. <code>safari_read_page</code> is great for "what does this say." But when the agent needs to <em>act</em> — click a button, fill a field — text isn't enough. It needs to know what's actually there and how to target it. For that, the better first move is <code>safari_snapshot</code>:</p>
<pre><code class="language-json">{ "tool": "safari_snapshot", "arguments": {} }
</code></pre>
<p>This returns an accessibility-tree view of the page, where every interactive element has a stable <code>ref</code> ID:</p>
<pre><code class="language-plaintext">[textbox ref=0_8] "Full Name" value=""
[combobox ref=0_10] "Subject"
[button ref=0_15] "Submit"
</code></pre>
<p>Those <code>ref</code> IDs are the agent's reliable handles. CSS selectors break when a page re-renders. A snapshot ref stays valid for the life of the page. Keep that in mind — it's the difference between an automation that works once and one that works every time.</p>
<h2 id="heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</h2>
<p>Reading example.com is a wiring test. Here's the thing a headless browser genuinely cannot do.</p>
<p>Pick a site you're logged into in Safari right now — your analytics, your project board, your CI dashboard. We'll use GitHub, because every developer has an account and the notifications page is a real, mildly annoying chore. The task: <strong>have the agent open your GitHub notifications and summarize what actually needs your attention.</strong></p>
<p>Ask the agent:</p>
<blockquote>
<p>"Open my GitHub notifications, read them, and group them into 'needs a reply' versus 'just FYI'."</p>
</blockquote>
<p>The agent navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://github.com/notifications" } }
</code></pre>
<p>Stop and notice what <em>didn't</em> happen. No login screen. No OAuth dance. No personal access token in an environment variable. Safari is already authenticated as you, so the agent lands directly on your real notifications. A headless Chromium would have hit a login wall here and stopped.</p>
<p>Notification lists load incrementally, so the agent should wait for content before reading. <code>safari_wait_for</code> polls the page until a selector or piece of text appears, or a timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "text": "Inbox", "timeout": 10000 } }
</code></pre>
<p>Then it reads. <code>safari_read_page</code> scoped to the notifications region returns the list as clean text:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": { "selector": "main" } }
</code></pre>
<p>The agent reasons over that text and hands you the grouped summary. The whole loop — navigate, wait, read, summarize — is a handful of tool calls.</p>
<p>When you need data in a precise shape rather than prose — to feed another step, or to write to a file — the agent can reach for <code>safari_evaluate</code>, which runs custom JavaScript on the page and returns whatever you build:</p>
<pre><code class="language-json">{
  "tool": "safari_evaluate",
  "arguments": {
    "expression": "JSON.stringify([...document.querySelectorAll('li')].map(li =&gt; li.innerText.trim()))"
  }
}
</code></pre>
<p>The agent writes that expression itself, against the structure it just saw in the snapshot — you don't hand-author selectors.</p>
<p>You might be thinking: <em>GitHub has an API, why scrape the page?</em> Fair. For GitHub specifically, the API is excellent. But the point generalizes. Most of the dashboards you stare at every day — your billing portal, your error tracker's specific filtered view, a client's analytics, the admin panel of some tool your company pays for — either have no usable API or would cost you an afternoon of OAuth setup to reach. With Safari MCP, "the page I'm already looking at" <em>is</em> the API. The agent reads what you can see, because it's using the browser you're seeing it in.</p>
<p>That's the capability headless automation can't match. Not speed, not features — <strong>access.</strong></p>
<h2 id="heading-handling-the-tricky-parts">Handling the Tricky Parts</h2>
<p>A first automation always looks easy. Three things tend to bite on the second one.</p>
<h3 id="heading-tab-safety-the-agent-must-not-hijack-your-tabs">Tab Safety — The Agent Must not Hijack Your Tabs</h3>
<p>This is the scariest failure mode: you're typing in a tab, the agent navigates <em>that</em> tab, and your work is gone. Safari MCP guards against it by stamping each automation tab with an identity marker — it uses <code>window.name</code>, which survives page navigations — and resolving "the agent's tab" through that marker on every call. If it can't positively identify its own tab, it refuses to act and raises a re-anchor error rather than guessing.</p>
<p>The practical rule for you: let the agent open its own tab with <code>safari_new_tab</code>, and it will stay in its lane. Don't point it at "the current tab" and assume.</p>
<h3 id="heading-waiting-for-dynamic-content">Waiting for Dynamic Content</h3>
<p>Modern pages render after load. If the agent reads too early, it reads an empty shell. Don't have it guess with fixed sleeps — use <code>safari_wait_for</code>, which polls for a selector or text until it appears or the timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "selector": ".results-list", "timeout": 8000 } }
</code></pre>
<p>This is the single most common fix for "the automation works when I step through it slowly but fails when it runs."</p>
<h3 id="heading-framework-forms">Framework Forms</h3>
<p>Set a React or Vue input's <code>.value</code> directly and the framework never notices — its internal state stays empty, and your "filled" form submits blank. Safari MCP's <code>safari_fill</code> and <code>safari_fill_form</code> use the native value setters and dispatch the <code>input</code> and <code>change</code> events the framework listens for, so React, Vue, Angular, and Svelte state all stay in sync:</p>
<pre><code class="language-json">{
  "tool": "safari_fill_form",
  "arguments": {
    "fields": [
      { "selector": "#email", "value": "jane@example.com" },
      { "selector": "#message", "value": "Looks great." }
    ]
  }
}
</code></pre>
<p>For framework-heavy pages where CSS selectors are fragile, go back to the snapshot refs from the previous section — pass <code>{ "ref": "0_9" }</code> instead of <code>{ "selector": "#email" }</code>. Refs survive re-renders; selectors don't.</p>
<p>None of these are exotic. They're just the difference between a demo and an automation you'd actually leave running.</p>
<h2 id="heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</h2>
<p>A tool tutorial that only lists strengths isn't worth much. Here's where Safari MCP is the wrong choice.</p>
<p><strong>It's macOS-only, and that's structural.</strong> Safari MCP is built on WebKit and AppleScript. There's no Windows or Linux port coming, because the foundation doesn't exist on those platforms. If your agent runs in Linux CI, use Playwright.</p>
<p><strong>It drives one Safari, on one Mac.</strong> This is browser automation for <em>your</em> machine — a coding agent working alongside you. It is not a fleet. If you need 50 parallel browsers scraping in a data center, that's a headless-Chromium-in-containers job, and Safari MCP is the wrong shape for it.</p>
<p><strong>Cross-browser test suites should stay on Playwright.</strong> If you're writing end-to-end tests that must pass on Chrome, Firefox, and Safari, use the tool built for that. Safari MCP drives exactly one engine: WebKit.</p>
<p><strong>It shares a browser with you.</strong> Because it uses your real Safari, the agent and you are in the same browser. That's the entire point — but it means you should let the agent work in its own tabs and not fight it for the same window.</p>
<p>The honest summary: Safari MCP is built for one specific situation — an AI agent doing real browser work on the Mac you're sitting at, against sites you're already logged into. In that situation it's hard to beat. Outside it, reach for the headless tools. Knowing which situation you're in is the actual skill.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>You've gone from an AI agent that could only see code to one that can see the web — the real web, behind your real logins.</p>
<p>To recap what you did: you learned what MCP is and why browser automation belongs behind that interface. You saw why a native Safari engine beats a headless Chromium for an agent working on your Mac and you installed Safari MCP with one command and two settings. You ran a first read, and then you did the thing that actually matters — an automation inside a logged-in page, with no auth code at all. Finally, you saw the edges: tab safety, waiting for dynamic content, framework forms, and the cases where you should pick a different tool.</p>
<p>The bigger idea is worth holding onto. An AI agent is only as capable as the tools you connect to it. Giving it a browser — a <em>real</em> one — turns "write me code" into "go look at the staging site, find the bug, and tell me what's wrong." That's a different kind of collaborator.</p>
<p>Safari MCP is open source under the MIT license, and it exposes around 80 tools beyond the handful you used here — screenshots, network inspection, storage, accessibility audits, multi-tab workflows. The repository and full tool reference are at <a href="https://github.com/achiya-automation/safari-mcp">github.com/achiya-automation/safari-mcp</a>. Point your agent at it and see what it does when it can finally look around.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use GitHub Search Like a Pro ]]>
                </title>
                <description>
                    <![CDATA[ GitHub is a popular code collaboration platform for developers. You can use it to share, manage, and contribute to open-source codebases, save and work on your own code, and more. And to be a more eff ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-github-search-like-a-pro/</link>
                <guid isPermaLink="false">6a0f57a1d8e265f60d4f8624</guid>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ open source ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rajdeep Singh ]]>
                </dc:creator>
                <pubDate>Thu, 21 May 2026 19:06:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/0e61e7e1-619c-4a66-b994-6a888100d0dd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>GitHub is a popular code collaboration platform for developers. You can use it to share, manage, and contribute to open-source codebases, save and work on your own code, and more.</p>
<p>And to be a more effective GitHub user, you'll need to know how to search within the platform.</p>
<p>This involves using qualifiers to efficiently filter through millions of repositories and billions of lines of code. Precise queries help you locate specific function definitions, projects, people, issues, pull requests, code, security vulnerabilities, or contribution opportunities.</p>
<p>In this tutorial, you'll learn how to use GitHub search, whether you're a beginner or a pro developer.</p>
<p>To enhance your learning, I've divided this article into two sections:</p>
<ol>
<li><p>Basic Search Functionality</p>
</li>
<li><p>Advanced Search Functionality</p>
</li>
</ol>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="https://stackedit.io/app#heading-what-well-cover">What We’ll Cover:</a></p>
</li>
<li><p><a href="https://stackedit.io/app#heading-basic-search-functionality">Basic Search Functionality</a></p>
<ul>
<li><p><a href="https://stackedit.io/app#heading-how-to-search-globally">How to Search Globally</a></p>
</li>
<li><p><a href="https://stackedit.io/app#heading-how-to-do-a-scoped-search-for-a-particular-repo-or-organization">How to Do a Scoped Search (for a Particular Repo or Organization)</a></p>
</li>
</ul>
</li>
<li><p><a href="https://stackedit.io/app#heading-advanced-search-functionality">Advanced Search Functionality</a></p>
<ul>
<li><p><a href="https://stackedit.io/app#heading-search-qualifiers">Search Qualifiers</a></p>
</li>
<li><p><a href="https://stackedit.io/app#heading-how-to-save-searches">How to Save Searches</a></p>
</li>
<li><p><a href="https://stackedit.io/app#heading-how-to-manage-saved-searches-on-github">How to Manage Saved Searches on GitHub</a></p>
</li>
<li><p><a href="https://stackedit.io/app#heading-why-do-we-need-github-advanced-search">Why Do We Need GitHub Advanced Search?</a></p>
</li>
</ul>
</li>
<li><p><a href="https://stackedit.io/app#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-basic-search-functionality">Basic Search Functionality</h2>
<p>Basic search here refers to the most commonly used search functionalities that are fast and easy to use.</p>
<p>To start, click the GitHub search icon, type your query, and GitHub will display your results. With basic search, you can search globally across all of GitHub or narrow your search to a specific repository or organization.</p>
<h3 id="heading-how-to-search-globally">How to Search Globally</h3>
<p>To search on GitHub, click the search tab or press the <code>/</code> key to open the search bar.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/32ae027d-4d34-4200-b2ae-da12ec67afcf.png" alt="Open search bar input filed on github" style="display: block;" width="1904" height="515" loading="lazy">

<p>To search globally (across all of GitHub), open the search input, type your query, and select "Search all of GitHub" from the dropdown menu or press enter.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/1de2af9b-c281-463a-ac60-08322d121e84.png" alt="global search on github" style="display: block;" width="1920" height="961" loading="lazy">

<p>After clicking the "Search all of GitHub" button, you'll be directed to a page displaying all results related to your query.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/8ea8f5cb-04e8-4376-9e15-f6258f216cc6.png" alt="display all results related to your query in Github" style="display: block;" width="1920" height="961" loading="lazy">

<h3 id="heading-how-to-do-a-scoped-search-for-a-particular-repo-or-organization">How to Do a Scoped Search (for a Particular Repo or Organization)</h3>
<p>To search within a specific repository or organization, go to the repository or organization page, enter your query in the search field at the top, and press Enter.</p>
<p>For instance, if you're searching for a file name starting with "pnpm" in the <a href="https://github.com/frontendweb3/frontendweb">frontendweb</a> repository:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/5b714b9d-157c-4c0f-a710-fef3fa2365d1.png" alt="Scoped Search in Github" style="display: block;" width="1920" height="961" loading="lazy">

<p>In the search bar, you'll see four suggestions: the first is "Search in this repository," the second is "Search in this organization," the third is "Search all of GitHub," and the last is the code section "Display similar files." Clicking a file opens it in the GitHub web editor.</p>
<h2 id="heading-advanced-search-functionality">Advanced Search Functionality</h2>
<p>In addition to the GitHub search bar, you can search on GitHub using the <a href="https://github.com/search/advanced">advanced search</a> page.</p>
<p>GitHub's advanced search allows you to find specific code, repositories, and issues. You can filter your searches by factors such as the number of stars, owners, forks, followers, programming language, and creation dates.</p>
<p>As you complete the advanced search fields, your query is automatically generated in the top search bar, and you can click on the Search button.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/518efaf5-f3c0-48bb-a374-e00d31fc5a84.png" alt="GitHub advanced search page" style="display: block;" width="1920" height="961" loading="lazy">

<p>For a basic example, let's search for <strong>React</strong> on GitHub, including recent pull and push requests, issues, commits, discussions, and so on, related to ReactJS. We can use GitHub's advanced search functionality for this. Type "React" in the first input field as text and add the owner, in this case, Facebook, to find everything related to ReactJS in the Facebook organization or user.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/df1c1c8b-3bff-4f77-a008-4659280d9c92.png" alt="Basic example of GitHub's advanced search functionality." style="display: block;" width="1920" height="2167" loading="lazy">

<p>After clicking on the Search button, you should see the following results page:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/b6c638c5-15a1-491e-9304-a6b070203773.png" alt="Show the query result of GitHub's advanced search" style="display: block;" width="1920" height="961" loading="lazy">

<p>As you can see, GitHub's advanced search functionality lets you find specific code, repositories and issues using a powerful set of qualifiers and options. Let's talk more about qualifiers now.</p>
<h3 id="heading-search-qualifiers">Search Qualifiers</h3>
<p>You can filter your search directly using various key qualifiers. We can divide these qualifiers into different sections:</p>
<h4 id="heading-advanced-options">Advanced Options</h4>
<ul>
<li><p><strong>From these owners</strong>: type the specific user's or organization's name, such as GitHub, Atom, Electron, Octokit, and so on.</p>
</li>
<li><p><strong>In these repositories</strong>: type the specific user's or organization's name, such as Facebook/React, Vercel/Next.js, and so on.</p>
</li>
<li><p><strong>Created on these dates</strong>: Specify the repository creation date, for example <code>&gt;2016-04-29</code>, <code>=2016-04-29</code>, etc., to learn more, check out the <a href="https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates"><strong>Query for dates</strong></a> documentation.</p>
</li>
<li><p><strong>Written in this language</strong>: Select the specific language that matches repositories from lists: JavaScript, TypeScript, Rust, and so on, or what it’s written in.</p>
</li>
</ul>
<h4 id="heading-repository-options">Repository Options</h4>
<ul>
<li><p><strong>With this many stars</strong>: Type the number of stars in the <code>stars:</code> field to filter and find repositories by star count. You can apply comparisons like &gt;1000 (more than 1000 stars) or =1000 (exactly 1000 stars) to narrow results based on popularity.</p>
</li>
<li><p><strong>With this many forks</strong>: Type the number of forks to filter and find repositories by fork count. You can apply comparisons like 100..1000 (find repos with 100 to 1000 forks), &gt;1000 (more than 1000 forks), or =1000 (exactly 1000 forks) to narrow results based on popularity.</p>
</li>
<li><p><strong>Of this size</strong>: type the repository size in KB to filter and find repositories, for example, size of 10000 KB</p>
</li>
<li><p><strong>Pushed to</strong>: type the date to filter and find repositories, for example &gt;2013-02-01 matches repositories with the word "react" that were pushed to after January 2013.</p>
</li>
<li><p><strong>With this license</strong>: Select the license to filter or find repositories based on license, for example, those licensed under the Apache License 2.0.</p>
</li>
</ul>
<h4 id="heading-code-options">Code Options</h4>
<ul>
<li><p><strong>With this extension</strong>: type the extension, such as rb, py, or jpg, that you want to search on GitHub.</p>
</li>
<li><p><strong>In this path</strong>: Type the path to filter on GitHub to search for files by their location within a repository. For example, you can find a header.tsx file specifically inside the <code>./components</code> folder by combining filename: with path:.</p>
</li>
<li><p><strong>With this file name</strong>: Type the file name, such as app, footer, or header, that you want to search on GitHub.</p>
</li>
</ul>
<h4 id="heading-issue-options">Issue Options</h4>
<ul>
<li><p><strong>In the state</strong>: Select the issue state (whether the issue is open or closed), for example, libraries <code>state:open mentions:rajdeep</code> matches open issues that mention @rajdeep with the word "libraries," or <code>language:JavaScript state:open</code> matches open issues in JavaScript repositories.</p>
</li>
<li><p><strong>With this many comments</strong>: Enter the comment number based on the comment count. You can filter the issue, for example, <code>state:closed comments:&gt;100</code> matches closed issues with more than 100 comments, or <code>comments:500..1000</code> matches issues with comments ranging from 500 to 1,000.</p>
</li>
<li><p><strong>With the labels</strong>: Enter the label to filter or narrow your results by labels. Since issues can have multiple labels, you can list and add multiple label qualifiers for each issue.</p>
<p>For example, first, <code>label:bug label:resolved</code> matches issues with the labels "bug" and "resolved." Second, <code>label:bug,resolved</code> matches issues with the label "bug" or the label "resolved." Third, example <code>broken in:body -label:bug label:priority</code> matches issues with the word "broken" in the body, that lack the label "bug," but do have the label "priority."</p>
</li>
<li><p><strong>Opened by the author</strong>: Enter the name or username to filter or find issues created by a user or integration account, or filter the issues based on the author.</p>
<p>For example, <code>author:rajdeep</code> matches issues with the word "fixed" that were created by @rajdeep, or <code>author:octocat</code> matches issues created by the account named "octocat."</p>
</li>
<li><p><strong>Mentioning the users</strong>: Enter the name or username to find issues that mention the user. For example, fixed mentions:rajdeep matches issues with the word "fixed" that mention @rajdeep in the issue.</p>
</li>
<li><p><strong>Assigned to the users</strong>: Enter the name or username to find or filter issues based on the specific username assigned to the issue. For example, <code>state:open assignee:rajdeep</code> matches open issues that are assigned to @rajdeep.</p>
</li>
<li><p><strong>Updated before the date</strong>: Enter the date, filter issues based on the time of creation, or when they were last updated.</p>
<p>For example <code>language:c# created:&lt;2011-01-01 state:open</code> matches open issues that were created before 2011 in repositories written in C# or <code>weird in:body updated:&gt;=2013-02-01</code> matches issues with the word "weird" in the body that were updated after February 2013.</p>
</li>
</ul>
<h4 id="heading-user-options">User Options</h4>
<ul>
<li><p><strong>With this full name</strong>: Enter a full name to filter repositories whose name includes “rajdeep singh” on GitHub.</p>
</li>
<li><p><strong>From this location</strong>: Enter a location to find users on GitHub. For example, <code>location:russia language:javascript</code> returns users based in Russia whose repositories are primarily written in JavaScript.</p>
</li>
<li><p><strong>With this many followers</strong>: Enter a follower count to filter users by popularity. For example, <code>followers:&gt;=1000</code> finds users with 1,000 or more followers, while <code>followers:1..10 rajdeep</code> returns users with 1–10 followers whose name includes “rajdeep” on GitHub.</p>
</li>
<li><p><strong>With this many public repositories</strong>: Enter a repository count to filter users by the number of public repositories they have. For example, repos:&gt;10 finds users with more than 10 repositories, while repos:10..30 returns users who have between 10 and 30 public repositories on GitHub.</p>
</li>
<li><p><strong>Working in this language</strong>: Select the language to find users based on the primary languages of their repositories. For example, <code>language:javascript location:russia</code> returns users in Russia whose repositories are mostly written in JavaScript, while <code>language:javascript fullname:rajdeep</code> finds users with JavaScript repositories whose full name includes "rajdeep" on GitHub.</p>
</li>
</ul>
<h4 id="heading-wiki-options">Wiki Options</h4>
<ul>
<li><strong>Updated before the date</strong>: Enter a date to filter wiki pages containing “next.js” that were last updated after <code>2016-01-01</code> in your GitHub wiki.</li>
</ul>
<p>The best way to use advanced search qualifiers is to combine one or multiple qualifier/search options to achieve the best result.</p>
<h3 id="heading-how-to-save-searches">How to Save Searches</h3>
<p>I don't often use GitHub Advanced Search, but I used it to find open-source projects to learn from and contribute to. If you take a moment to fill in the information in GitHub Advanced Search, you can save the search for future use:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/323b109b-4a7d-4aaa-a7d0-c4e15cdd1f19.png" alt="Save the query result to GitHub" style="display: block;" width="1920" height="961" loading="lazy">

<p>Enter the name and click the "Create saved search" button:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/776a4684-ded9-433b-a35f-8cb28bec3a85.png" alt="Follow these steps to save the query results on GitHub." style="display: block;" width="1920" height="961" loading="lazy">

<p>You can show a list of all your saved searches:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/33c4f651-d325-4ea1-87a6-9663d3932881.png" alt="List of saved query results on GitHub." style="display: block;" width="1920" height="961" loading="lazy">

<h3 id="heading-how-to-manage-saved-searches-on-github">How to Manage Saved Searches on GitHub</h3>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/3d9b99d8-9763-4490-8e7f-4669c1b95ece.png" alt="Manage Saved Searches on GitHub" style="display: block;" width="1920" height="961" loading="lazy">

<p>To manage a saved search, open the search bar and type "saved:" in the search bar, then click the "Manage saved searches" button.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5dd3ab9cc4d1027248f20c91/68616625-8c23-4f3d-90d2-e6e6b7b6568d.png" alt="Delete and edit the saved searches on GitHub." style="display: block;" width="1920" height="961" loading="lazy">

<p>To edit a saved search, click the pencil icon next to it. To delete a saved search, click the trash icon.</p>
<h3 id="heading-why-do-we-need-github-advanced-search">Why Do We Need GitHub Advanced Search?</h3>
<p>As mentioned, GitHub Advanced Search helps you find the best issues to contribute to in open source repositories.</p>
<p>For example, I'm an expert in Next.js and React.js, and I can use GitHub Advanced Search to locate suitable issues in open-source projects for contribution.</p>
<p>Even as a beginner developer, you can use GitHub Advanced Search to find "good first issues" labeled by maintainers, making it easier to contribute.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>GitHub Advanced Search is versatile. t's not just for searching but also for researching recent issues, pull requests, and push requests that may be related to your query, repository, user, or anything else. My favorite use is finding open-source contribution opportunities with GitHub Advanced Search.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Develop Chrome Extensions using Plasmo [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Chrome extensions are lightweight tools that enhance and personalize your browsing experience, whether that's managing passwords, translating pages, or adding entirely new features to websites you use ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-develop-chrome-extensions-using-plasmo-handbook/</link>
                <guid isPermaLink="false">6a0237edfca21b0d4b636175</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Preston Mayieka ]]>
                </dc:creator>
                <pubDate>Mon, 11 May 2026 20:11:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e0d0bca4-a2e8-495a-9c1c-4f0b9ef52630.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Chrome extensions are lightweight tools that enhance and personalize your browsing experience, whether that's managing passwords, translating pages, or adding entirely new features to websites you use every day.</p>
<p>Millions of developers have published extensions to the Chrome Web Store, and building one is more approachable than you might think.</p>
<p>In this handbook you'll go from zero to a published Chrome extension using TypeScript, React, and Plasmo, a modern framework that handles the repetitive setup and configuration so you can focus on writing features instead of boilerplate.</p>
<p>Along the way you'll touch the real Chrome extension APIs that power production extensions: querying tabs, creating tab groups, and passing messages between different parts of an extension.</p>
<p>By the end you'll have working code, a mental model of how extensions are structured, and everything you need to publish your own ideas to the Chrome Web Store.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-plasmo">What is Plasmo?</a></p>
</li>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-understanding-the-background-script">Understanding the Background Script</a></p>
</li>
<li><p><a href="#heading-building-the-popup-ui">Building the Popup UI</a></p>
</li>
<li><p><a href="#heading-testing-your-extension">Testing Your Extension</a></p>
</li>
<li><p><a href="#heading-next-steps-and-extension-ideas">Next Steps and Extension Ideas</a></p>
</li>
<li><p><a href="#heading-deploying-to-chrome-web-store">Deploying to Chrome Web Store</a></p>
</li>
</ul>
<h2 id="heading-what-is-plasmo">What is Plasmo?</h2>
<p><a href="https://www.plasmo.com/">Plasmo</a> is an open-source framework for building browser extensions. Think of it as the equivalent of Create React App or Next.js, but for Chrome extensions.</p>
<p>Without Plasmo, building a Chrome extension requires manually writing a <code>manifest.json</code> file, wiring up build tooling, and configuring TypeScript and React yourself. Plasmo handles all of that.</p>
<p>A single command scaffolds a working project with TypeScript and React already configured. It reads your <code>package.json</code> and generates the <code>manifest.json</code> Chrome requires, so you never edit it directly.</p>
<p>Moreover, changes to your source files automatically rebuild and reload the extension in Chrome during development, and full type safety including types for Chrome's own APIs is available out of the box.</p>
<p>Plasmo doesn't hide the Chrome extension concepts from you. You still use <code>chrome.tabs</code>, <code>chrome.runtime</code>, and the rest of the Chrome APIs directly. It just removes the tedious scaffolding so you can start building immediately.</p>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>In this tutorial, you'll build a <strong>Tab Grouper</strong> Chrome extension from scratch.</p>
<p>This extension automatically organizes your browser tabs by grouping them based on their website domain.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/43f51cde-41c8-46ac-9305-6b4ad5adc1ac.gif" alt="Animated demo of the Tab Grouper extension grouping open tabs into colored groups by domain" style="display: block;" width="800" height="520" loading="lazy">

<h3 id="heading-example-use-case">Example Use Case</h3>
<p>Imagine you have 20 tabs open: 5 from GitHub, 4 from YouTube, 3 from Stack Overflow, and 8 from other websites.</p>
<p>With one click, the Tab Grouper extension will automatically create colored groups for each website, making it straightforward to find and manage your tabs.</p>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<p>By completing this tutorial, you'll get hands-on experience in three areas.</p>
<p>First, <strong>Chrome Extension Basics</strong>: how extensions work under the hood, the anatomy of an extension (manifest, background scripts, popups), and how to load and test extensions in Chrome during development.</p>
<p>Second, <strong>Chrome APIs</strong>: specifically <code>chrome.tabs</code> for managing browser tabs, <code>chrome.tabGroups</code> for creating and customizing tab groups, and <code>chrome.runtime</code> for passing messages between different parts of your extension.</p>
<p>Third, <strong>Modern Web Development tooling</strong>: TypeScript for type-safe JavaScript, React for building the popup UI, and the Plasmo framework that ties it all together.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need to be an expert in any of these, but you'll have the smoothest experience if you're comfortable with basic JavaScript or TypeScript and have a general understanding of HTML and CSS.</p>
<p>Some familiarity with React is helpful but not required. The pop-up component we'll build is simple enough to follow even if you're new to it.</p>
<p>On the software side, you'll need Node.js version 18 or higher (<a href="https://nodejs.org/">download here</a>), Google Chrome, a code editor (VS Code is recommended), and pnpm as your package manager.</p>
<h3 id="heading-verify-your-setup">Verify Your Setup</h3>
<p>Open your terminal and run these commands to confirm everything is installed:</p>
<pre><code class="language-bash">node --version
# Should output v18.0.0 or higher

npm --version
# Should output 9.0.0 or higher
</code></pre>
<h3 id="heading-getting-help">Getting Help</h3>
<p>If you get stuck, review the complete code in the repository, consult the Chrome Extension documentation, or ask for help in the community forums.</p>
<h3 id="heading-ready-to-begin">Ready to Begin?</h3>
<p>In the next section, you'll set up your development environment and create your first Chrome extension project.</p>
<p>Let's get started!</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>In this section, you'll use Plasmo to scaffold your Chrome extension project, then customize it for the Tab Grouper.</p>
<p>Rather than creating files manually, you'll let Plasmo generate a starter project with all required configuration, then explore what was created before customizing it for our needs.</p>
<h2 id="heading-step-1-install-pnpm-recommended">Step 1: Install pnpm (Recommended)</h2>
<p>Plasmo officially recommends <strong>pnpm</strong> for faster installs and better disk space usage. Check if you already have it:</p>
<pre><code class="language-bash">pnpm --version
</code></pre>
<p>If you see a version number, skip to Step 2.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/aeed7b06-a403-4fe2-81fe-571a00219acf.png" alt="Terminal output showing pnpm version number after running pnpm --version" style="display: block;" width="1126" height="460" loading="lazy">

<p>If you get "command not found", install it with:</p>
<pre><code class="language-bash">npm install -g pnpm
</code></pre>
<h2 id="heading-step-2-create-your-extension-project">Step 2: Create Your Extension Project</h2>
<p>Run this command to create a new Plasmo project:</p>
<pre><code class="language-bash">pnpm create plasmo tab-grouper
</code></pre>
<p>You'll see:</p>
<pre><code class="language-plaintext">🟣 Creating a new Plasmo extension
📁 Project name: tab-grouper
? Extension description: (Give your extension a nice description)
? Author name: (Your Name)
</code></pre>
<p>Plasmo will then scaffold the project and install dependencies automatically. You might be prompted to enter a description and author name.</p>
<p>Fill these in however you like.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/e0a58818-0bec-42a7-bde3-c7a66de68b7a.png" alt="Terminal output showing Plasmo scaffolding a new project called tab-grouper and installing dependencies." style="display: block;" width="1652" height="530" loading="lazy">

<h3 id="heading-step-3-navigate-to-your-project">Step 3: Navigate to Your Project</h3>
<pre><code class="language-bash">cd tab-grouper
</code></pre>
<h3 id="heading-step-4-explore-what-was-created">Step 4: Explore What Was Created</h3>
<p>List the files that Plasmo generated:</p>
<pre><code class="language-bash">ls -la
</code></pre>
<p>You should see something like this:</p>
<pre><code class="language-plaintext">tab-grouper/
├── .git/                 # Git repository (already initialized!)
├── .github/              # GitHub Actions workflows
├── assets/
│   └── icon.png          # Default Plasmo icon 
├── node_modules/         # Dependencies (already installed!)
├── package.json          # Project configuration
├── popup.tsx             # Default popup 
├── .prettierrc.cjs       # Code formatting rules
├── .gitignore            # Git ignore rules
├── README.md             # Default readme
└── tsconfig.json         # TypeScript configuration
</code></pre>
<p>The key files to know about:</p>
<ul>
<li><p><strong>assets/icon.png</strong>: The extension icon required by Chrome.</p>
</li>
<li><p><strong>package.json</strong>: Lists dependencies and scripts, and is where you configure the extension manifest.</p>
</li>
<li><p><strong>popup.tsx</strong>: The UI that appears when you click the extension icon.</p>
</li>
<li><p><strong>tsconfig.json</strong>: Contains TypeScript settings that are already correctly configured.</p>
</li>
</ul>
<h3 id="heading-step-5-test-the-default-extension">Step 5: Test the Default Extension</h3>
<p>Make sure everything works <strong>before</strong> you customize it.</p>
<p>You can do this by starting the development server:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">🟣 Plasmo v0.90.5
🔴 The Browser Extension Framework
🔵 INFO   | Starting the extension development server...
🔵 INFO   | Building for target: chrome-mv3
🔵 INFO   | Loaded environment variables from: []
🟢 DONE   | Extension re-packaged in 1842ms! 🚀

View Extension:
📦 build/chrome-mv3-dev
</code></pre>
<p>Your extension is ready. Keep this terminal window open.</p>
<p>Plasmo watches for file changes and rebuilds automatically.</p>
<h3 id="heading-step-6-load-the-extension-in-chrome">Step 6: Load the Extension in Chrome</h3>
<p>Now load the extension into Chrome to test it:</p>
<ol>
<li><p>Open Google Chrome</p>
</li>
<li><p>Go to <code>chrome://extensions/</code></p>
</li>
<li><p>Enable <strong>Developer mode</strong> (toggle in top-right)</p>
</li>
<li><p>Click <strong>"Load unpacked"</strong></p>
</li>
<li><p>Navigate to your project folder</p>
</li>
<li><p>Select the <code>build/chrome-mv3-dev</code> folder</p>
</li>
<li><p>Click "Select Folder"</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/19cef596-a9d1-4709-8d27-594381d03842.gif" alt="Animated gif showing how to load an unpacked extension in Chrome via the Extensions page developer mode" style="display: block;" width="800" height="461" loading="lazy">

<p>Your extension should now appear in the list.</p>
<h3 id="heading-step-7-test-the-default-popup">Step 7: Test the Default Popup</h3>
<ol>
<li><p>Click the puzzle piece icon in Chrome's toolbar</p>
</li>
<li><p>Find "tab-grouper" and pin it</p>
</li>
<li><p>Click the extension icon</p>
</li>
</ol>
<p>You will see a default popup that says "Welcome to Plasmo!"</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/56bad298-b07e-41c5-a648-49e382e0c51b.png" alt="The default Plasmo popup showing a Welcome to Plasmo message in the Chrome toolbar popup" style="display: block;" width="846" height="616" loading="lazy">

<p>The extension is working. Now you can customize it.</p>
<h3 id="heading-step-8-update-extension-information">Step 8: Update Extension Information</h3>
<p>Open <code>package.json</code> in your editor. This file stores metadata about your project. name, version, description, dependencies, and scripts for building and running your extension.</p>
<p>Find these lines near the top:</p>
<pre><code class="language-json">{
  "name": "tab-grouper",
  "displayName": "tab-grouper",
  "version": "0.0.0",
  "description": "A basic Plasmo extension.",
</code></pre>
<p>Change them to:</p>
<pre><code class="language-json">{
  "name": "tab-grouper",
  "displayName": "Tab Grouper",
  "version": "1.0.0",
  "description": "A simple Chrome extension - group tabs by domain",
</code></pre>
<p>Save the file.</p>
<h3 id="heading-step-9-add-required-permissions-critical">Step 9: Add Required Permissions (Critical!)</h3>
<p><strong>This is a critical step.</strong> Without permissions, your extension will fail with errors like:</p>
<pre><code class="language-plaintext">TypeError: Cannot read properties of undefined (reading 'query')
</code></pre>
<p>Chrome extensions must declare which browser APIs they intend to use. In <code>package.json</code>, find the <code>"manifest"</code> section.</p>
<p>It looks like this:</p>
<pre><code class="language-json">"manifest": {
  "host_permissions": [
    "https://*/*"
  ]
}
</code></pre>
<p>Replace it with:</p>
<pre><code class="language-json">"manifest": {
  "permissions": [
    "tabs",
    "tabGroups"
  ]
}
</code></pre>
<p>Save the file. The <code>tabs</code> permission allows you to read tab information (required for <code>chrome.tabs.query()</code>), and <code>tabGroups</code> allows you to create and manage tab groups (required for <code>chrome.tabGroups.update()</code>).</p>
<h3 id="heading-finding-the-right-permissions-for-your-own-extensions">Finding the right permissions for your own extensions:</h3>
<p>The <a href="https://developer.chrome.com/docs/extensions/reference/permissions-list">Chrome Extension Permissions Reference</a> lists every available permission and what it unlocks.</p>
<p>Each API's documentation page also lists which permissions it requires, for example, the <a href="https://developer.chrome.com/docs/extensions/reference/api/tabs">chrome.tabs API page</a> specifies the <code>"tabs"</code> permission.</p>
<p>If you're using Plasmo, the <a href="https://docs.plasmo.com/framework/customization/manifest">Manifest Configuration docs</a> explain how to add permissions through <code>package.json</code>.</p>
<p>As a general rule: if you're getting <code>undefined</code> errors when calling a Chrome API, a missing permission is the first thing to check.</p>
<h3 id="heading-step-10-verify-hot-reload-works">Step 10: Verify Hot Reload Works</h3>
<p>Plasmo automatically reloads your extension when you save changes.</p>
<p>Check the terminal where <code>pnpm dev</code> is running. After saving <code>package.json</code> you should see something like:</p>
<pre><code class="language-plaintext">🔄 Reloading extension...
✅ Ready in 0.8s
</code></pre>
<p>Your project is now ready: a working extension loaded in Chrome, a development server running with hot reload, and the required permissions in place.</p>
<p>Leave the dev server running and the extension loaded as you work through the next sections. Your changes will reload automatically.</p>
<h3 id="heading-section-summary">Section Summary</h3>
<p>In this section you installed pnpm, scaffolded a new extension with <code>pnpm create plasmo</code>, explored the generated project structure, started the development server, loaded the extension in Chrome, and updated the extension metadata and permissions.</p>
<p><strong>Next:</strong> You'll create the background script that handles the tab grouping logic.</p>
<h2 id="heading-understanding-the-background-script">Understanding the Background Script</h2>
<p>The background script is the heart of your extension. It runs persistently behind the scenes and contains the core logic.</p>
<p>In this case, the code that groups your tabs by domain.</p>
<h3 id="heading-what-is-a-background-script">What is a Background Script?</h3>
<p>A background script runs continuously even when the popup is closed.</p>
<p>It can listen to browser events like tabs opening, closing, or updating, perform tasks that don't require direct user interaction, and communicate with other parts of the extension by passing messages.</p>
<p>Think of it as the server-side of your extension. The popup is just a UI that talks to it.</p>
<h3 id="heading-step-1-create-backgroundts">Step 1: Create background.ts</h3>
<p>Plasmo's scaffolding didn't create a background script by default, so you'll create this file from scratch. Create a new file called <code>background.ts</code> in your project root (the same level as <code>popup.tsx</code>):</p>
<pre><code class="language-typescript">export {}

// Background script - runs in the background and handles tab grouping logic

console.log("Tab Grouper background script loaded!")

// Listen for messages from the popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) =&gt; {
  if (message.type === "GROUP_TABS") {
    groupTabsByDomain()
    sendResponse({ success: true })
  }
  return true
})
</code></pre>
<p>The <code>export {}</code> at the top is required by Plasmo to treat this file as a module. Without it you may get errors about conflicting global variable declarations.</p>
<p>The <code>console.log</code> will help you verify the script loaded correctly (you'll see it in the extension's DevTools console). <code>chrome.runtime.onMessage</code> sets up a listener so the background script can receive instructions from the popup.</p>
<p>When it receives a <code>"GROUP_TABS"</code> message, it calls the grouping function.</p>
<p>You can read more about this messaging pattern in the <a href="https://developer.chrome.com/docs/extensions/develop/concepts/messaging">Chrome Extensions documentation</a>.</p>
<h3 id="heading-step-2-implement-tab-grouping-logic">Step 2: Implement Tab Grouping Logic</h3>
<p>Now add the main grouping function below the message listener:</p>
<pre><code class="language-typescript">async function groupTabsByDomain() {
  try {
    // Step 1: Get all tabs in the current window
    const tabs = await chrome.tabs.query({ currentWindow: true })

    // Step 2: Create a Map to organize tabs by domain
    const domainGroups = new Map&lt;string, chrome.tabs.Tab[]&gt;()

    // Step 3: Loop through each tab and group by domain
    tabs.forEach(tab =&gt; {
      // Skip tabs without URLs
      if (!tab.url) return

      // Extract the domain from the URL
      const domain = getDomainFromUrl(tab.url)

      // Skip invalid domains (like chrome:// pages)
      if (!domain) return

      // Add tab to the appropriate domain group
      if (!domainGroups.has(domain)) {
        domainGroups.set(domain, [])
      }
      domainGroups.get(domain)!.push(tab)
    })

    // Step 4: Create tab groups for each domain (only if 2+ tabs)
    for (const [domain, domainTabs] of domainGroups) {
      // Skip domains with only 1 tab
      if (domainTabs.length &lt; 2) continue

      // Get all tab IDs
      const tabIds = domainTabs
        .map(t =&gt; t.id!)
        .filter(id =&gt; id !== undefined)

      if (tabIds.length === 0) continue

      // Create the tab group
      const groupId = await chrome.tabs.group({ tabIds })

      // Customize the group with a title and color
      await chrome.tabGroups.update(groupId, {
        title: domain,
        color: getColorForDomain(domain) // Randomized Tab Group colors.
      })
    }

    console.log(`Successfully grouped ${domainGroups.size} domains`)
  } catch (error) {
    console.error("Error grouping tabs:", error)
  }
}
</code></pre>
<p>The function starts by querying all tabs in the current window, then iterates over them to build a <code>Map</code> keyed by domain name.</p>
<p>Once every tab has been sorted into a domain bucket, it loops through the map and calls <code>chrome.tabs.group()</code> for any domain that has two or more tabs, then immediately customizes the resulting group with a title and color.</p>
<p>Domains with only a single tab are skipped. There's no point grouping a lone tab.</p>
<h3 id="heading-step-3-extract-domain-helper">Step 3: Extract Domain Helper</h3>
<p>Add a helper function to pull the hostname out of a URL:</p>
<pre><code class="language-typescript">function getDomainFromUrl(url: string): string | null {
  try {
    const urlObj = new URL(url)

    // Skip Chrome internal pages (chrome://, chrome-extension://)
    if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
      return null
    }

    // Remove "www." prefix and return the hostname
    return urlObj.hostname.replace(/^www\./, "")
  } catch {
    // Return null if URL is invalid
    return null
  }
}
</code></pre>
<p><code>new URL(url)</code> gives us a structured object to work with rather than string-parsing the URL manually.</p>
<p>The protocol check filters out Chrome's internal pages like <code>chrome://extensions</code> and <code>chrome://settings</code>, which extensions can't access.</p>
<p>The <code>.replace(/^www\./, "")</code> ensures that <code>www.github.com</code> and <code>github.com</code> are treated as the same domain rather than two separate groups.</p>
<p>The whole thing is wrapped in a try-catch so malformed URLs simply return <code>null</code> and get skipped.</p>
<p>In practice: <code>https://www.github.com/user/repo</code> becomes <code>github.com</code>, <code>https://youtube.com/watch?v=123</code> becomes <code>youtube.com</code>, and <code>chrome://extensions</code> returns <code>null</code>.</p>
<h3 id="heading-step-4-color-assignment-helper">Step 4: Color Assignment Helper</h3>
<p>Add a function to deterministically assign a color to each domain:</p>
<pre><code class="language-typescript">function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
  // Available colors in Chrome
  const colors: chrome.tabGroups.ColorEnum[] = [
    "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
  ]

  // Create a simple hash from the domain name
  let hash = 0
  for (let i = 0; i &lt; domain.length; i++) {
    hash = domain.charCodeAt(i) + ((hash &lt;&lt; 5) - hash)
  }

  // Return a color based on the hash
  return colors[Math.abs(hash) % colors.length]
}
</code></pre>
<p>Chrome supports eight colors for tab groups. Rather than assigning them randomly (which would change every time you group), this function hashes the domain name to a number and uses the modulo operator to pick a consistent index into the color array.</p>
<p>The result is that <code>github.com</code> always gets the same color across sessions, while different domains are likely to get different colors.</p>
<h3 id="heading-complete-backgroundts-file">Complete background.ts File</h3>
<p>Your complete <code>background.ts</code> should look like this:</p>
<pre><code class="language-typescript">export {}

console.log("Tab Grouper background script loaded!")

chrome.runtime.onMessage.addListener((message, sender, sendResponse) =&gt; {
  if (message.type === "GROUP_TABS") {
    groupTabsByDomain()
    sendResponse({ success: true })
  }
  return true
})

async function groupTabsByDomain() {
  try {
    const tabs = await chrome.tabs.query({ currentWindow: true })
    const domainGroups = new Map&lt;string, chrome.tabs.Tab[]&gt;()

    tabs.forEach(tab =&gt; {
      if (!tab.url) return
      const domain = getDomainFromUrl(tab.url)
      if (!domain) return

      if (!domainGroups.has(domain)) {
        domainGroups.set(domain, [])
      }
      domainGroups.get(domain)!.push(tab)
    })

    for (const [domain, domainTabs] of domainGroups) {
      if (domainTabs.length &lt; 2) continue

      const tabIds = domainTabs
        .map(t =&gt; t.id!)
        .filter(id =&gt; id !== undefined)

      if (tabIds.length === 0) continue

      const groupId = await chrome.tabs.group({ tabIds })

      await chrome.tabGroups.update(groupId, {
        title: domain,
        color: getColorForDomain(domain)
      })
    }

    console.log(`Successfully grouped ${domainGroups.size} domains`)
  } catch (error) {
    console.error("Error grouping tabs:", error)
  }
}

function getDomainFromUrl(url: string): string | null {
  try {
    const urlObj = new URL(url)
    if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
      return null
    }
    return urlObj.hostname.replace(/^www\./, "")
  } catch {
    return null
  }
}

function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
  const colors: chrome.tabGroups.ColorEnum[] = [
    "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
  ]

  let hash = 0
  for (let i = 0; i &lt; domain.length; i++) {
    hash = domain.charCodeAt(i) + ((hash &lt;&lt; 5) - hash)
  }

  return colors[Math.abs(hash) % colors.length]
}
</code></pre>
<h3 id="heading-testing-the-background-script">Testing the Background Script</h3>
<p>If your development server isn't already running from the previous section, start it:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>To verify the background script loaded correctly, go to <code>chrome://extensions</code>, find "Tab Grouper Tutorial", and click the <strong>"service worker"</strong> link.</p>
<p>A DevTools console will open and you should see "Tab Grouper background script loaded!" confirming everything is wired up.</p>
<h2 id="heading-building-the-popup-ui">Building the Popup UI</h2>
<p>The popup is the small window that appears when a user clicks your extension icon in the Chrome toolbar.</p>
<p>It can display information, provide buttons for actions, and show settings.</p>
<p>In this section you'll build a React-based popup that shows live tab statistics and triggers the grouping logic in the background script.</p>
<h3 id="heading-step-1-replace-popuptsx">Step 1: Replace popup.tsx</h3>
<p>When you ran <code>pnpm create plasmo</code>, a default <code>popup.tsx</code> was created that just displays a welcome message.</p>
<p>Open that file and replace <strong>all</strong> of its contents with this starting skeleton:</p>
<pre><code class="language-tsx">import { useState, useEffect } from "react"

function IndexPopup() {
  const [tabCount, setTabCount] = useState(0)
  const [groupCount, setGroupCount] = useState(0)
  const [isGrouping, setIsGrouping] = useState(false)

  return (
    &lt;div&gt;
      &lt;h2&gt;Tab Grouper&lt;/h2&gt;
      &lt;button&gt;Group Tabs&lt;/button&gt;
    &lt;/div&gt;
  )
}

export default IndexPopup
</code></pre>
<p>Save the file and the extension will automatically reload.</p>
<p>The three state variables track the number of open tabs, the number of existing groups, and whether a grouping operation is currently in progress.</p>
<p>That last one lets us disable the button and show a loading state so users can't trigger multiple groupings at once.</p>
<h3 id="heading-step-2-load-statistics">Step 2: Load Statistics</h3>
<p>Now add the logic to load tab and group counts when the popup opens. Add this inside the <code>IndexPopup</code> function, right after the state declarations:</p>
<pre><code class="language-tsx">// Load tab statistics when popup opens
useEffect(() =&gt; {
  loadStats()
}, [])

async function loadStats() {
  const tabs = await chrome.tabs.query({ currentWindow: true })
  const groups = await chrome.tabGroups.query({
    windowId: chrome.windows.WINDOW_ID_CURRENT
  })

  setTabCount(tabs.length)
  setGroupCount(groups.length)
}
</code></pre>
<p>The <code>useEffect</code> with an empty dependency array <code>[]</code> runs once when the component first mounts. In other words, every time the popup opens.</p>
<p>It calls <code>loadStats</code>, which queries Chrome for the current window's tabs and groups, then updates the state variables with the counts.</p>
<h3 id="heading-step-3-trigger-tab-grouping">Step 3: Trigger Tab Grouping</h3>
<p>Add the handler that sends a message to the background script when the button is clicked:</p>
<pre><code class="language-tsx">async function handleGroupTabs() {
  setIsGrouping(true)

  // Send message to background script
  await chrome.runtime.sendMessage({ type: "GROUP_TABS" })

  // Refresh statistics
  await loadStats()
  setIsGrouping(false)
}
</code></pre>
<p><code>chrome.runtime.sendMessage</code> delivers the <code>{ type: "GROUP_TABS" }</code> message to the listener we set up in <code>background.ts</code>.</p>
<p>After the background script finishes, we reload the statistics so the group count updates immediately, then re-enable the button.</p>
<h3 id="heading-step-4-build-the-ui">Step 4: Build the UI</h3>
<p>Replace the placeholder <code>return</code> statement with this complete, styled version:</p>
<pre><code class="language-tsx">return (
  &lt;div style={{
    width: 300,
    padding: 20,
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
  }}&gt;
    {/* Header */}
    &lt;div style={{ marginBottom: 20 }}&gt;
      &lt;h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}&gt;
        🗂️ Tab Grouper
      &lt;/h2&gt;
      &lt;p style={{ margin: "8px 0 0", fontSize: 13, color: "#666" }}&gt;
        Organize your tabs by domain
      &lt;/p&gt;
    &lt;/div&gt;

    {/* Statistics */}
    &lt;div style={{
      display: "flex",
      gap: 12,
      marginBottom: 20,
      padding: 12,
      background: "#f5f5f5",
      borderRadius: 8
    }}&gt;
      &lt;div style={{ flex: 1 }}&gt;
        &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#333" }}&gt;
          {tabCount}
        &lt;/div&gt;
        &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
          Open Tabs
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div style={{ flex: 1 }}&gt;
        &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#0066ff" }}&gt;
          {groupCount}
        &lt;/div&gt;
        &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
          Tab Groups
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    {/* Group Button */}
    &lt;button
      onClick={handleGroupTabs}
      disabled={isGrouping}
      style={{
        width: "100%",
        padding: "12px 16px",
        fontSize: 14,
        fontWeight: 500,
        color: "white",
        background: isGrouping ? "#ccc" : "#0066ff",
        border: "none",
        borderRadius: 8,
        cursor: isGrouping ? "not-allowed" : "pointer",
        transition: "background 0.2s"
      }}
    &gt;
      {isGrouping ? "Grouping..." : "🗂️ Group Tabs by Domain"}
    &lt;/button&gt;

    {/* Footer */}
    &lt;div style={{
      marginTop: 16,
      padding: 12,
      fontSize: 12,
      color: "#666",
      background: "#fff9e6",
      borderRadius: 6,
      border: "1px solid #ffe066"
    }}&gt;
      💡 &lt;strong&gt;Tip:&lt;/strong&gt; This will group all tabs in this window by their website domain.
    &lt;/div&gt;
  &lt;/div&gt;
)
</code></pre>
<p>The UI has four parts: a header with the extension title and a short description, a statistics box showing the live tab and group counts side by side, the main action button (which grays out and changes text to "Grouping..." while work is in progress), and a tip box at the bottom.</p>
<p>This tutorial uses inline styles for simplicity. In a production extension, you'd likely reach for CSS modules, Tailwind, or styled-components instead.</p>
<h3 id="heading-complete-popuptsx-file">Complete popup.tsx File</h3>
<p>Your complete <code>popup.tsx</code> should look like this:</p>
<pre><code class="language-tsx">import { useState, useEffect } from "react"

function IndexPopup() {
  const [tabCount, setTabCount] = useState(0)
  const [groupCount, setGroupCount] = useState(0)
  const [isGrouping, setIsGrouping] = useState(false)

  useEffect(() =&gt; {
    loadStats()
  }, [])

  async function loadStats() {
    const tabs = await chrome.tabs.query({ currentWindow: true })
    const groups = await chrome.tabGroups.query({
      windowId: chrome.windows.WINDOW_ID_CURRENT
    })

    setTabCount(tabs.length)
    setGroupCount(groups.length)
  }

  async function handleGroupTabs() {
    setIsGrouping(true)
    await chrome.runtime.sendMessage({ type: "GROUP_TABS" })
    await loadStats()
    setIsGrouping(false)
  }

  return (
    &lt;div style={{
      width: 300,
      padding: 20,
      fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
    }}&gt;
      &lt;div style={{ marginBottom: 20 }}&gt;
        &lt;h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}&gt;
          🗂️ Tab Grouper
        &lt;/h2&gt;
        &lt;p style={{ margin: "8px 0 0", fontSize: 13, color: "#666" }}&gt;
          Organize your tabs by domain
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div style={{
        display: "flex",
        gap: 12,
        marginBottom: 20,
        padding: 12,
        background: "#f5f5f5",
        borderRadius: 8
      }}&gt;
        &lt;div style={{ flex: 1 }}&gt;
          &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#333" }}&gt;
            {tabCount}
          &lt;/div&gt;
          &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
            Open Tabs
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div style={{ flex: 1 }}&gt;
          &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#0066ff" }}&gt;
            {groupCount}
          &lt;/div&gt;
          &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
            Tab Groups
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;button
        onClick={handleGroupTabs}
        disabled={isGrouping}
        style={{
          width: "100%",
          padding: "12px 16px",
          fontSize: 14,
          fontWeight: 500,
          color: "white",
          background: isGrouping ? "#ccc" : "#0066ff",
          border: "none",
          borderRadius: 8,
          cursor: isGrouping ? "not-allowed" : "pointer",
          transition: "background 0.2s"
        }}
      &gt;
        {isGrouping ? "Grouping..." : "🗂️ Group Tabs by Domain"}
      &lt;/button&gt;

      &lt;div style={{
        marginTop: 16,
        padding: 12,
        fontSize: 12,
        color: "#666",
        background: "#fff9e6",
        borderRadius: 6,
        border: "1px solid #ffe066"
      }}&gt;
        💡 &lt;strong&gt;Tip:&lt;/strong&gt; This will group all tabs in this window by their website domain.
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default IndexPopup
</code></pre>
<h2 id="heading-testing-your-extension">Testing Your Extension</h2>
<p>Now that you have both the background script and popup UI built, it's time to verify that everything works together in Chrome.</p>
<h3 id="heading-step-1-make-sure-the-dev-server-is-running">Step 1: Make Sure the Dev Server is Running</h3>
<p>If <code>pnpm dev</code> isn't already running from an earlier step, start it now:</p>
<pre><code class="language-bash">pnpm run dev # or pnpm dev
</code></pre>
<p>Plasmo will build the extension into <code>build/chrome-mv3-dev</code> and watch for changes.</p>
<h3 id="heading-step-2-load-the-extension-in-chrome">Step 2: Load the Extension in Chrome</h3>
<p>If you haven't already loaded the extension, go to <code>chrome://extensions/</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the <code>build/chrome-mv3-dev</code> folder.</p>
<p>Once loaded you should see the extension listed with the name "Tab Grouper Tutorial", version "1.0.0", and status Enabled.</p>
<h3 id="heading-step-3-pin-the-extension">Step 3: Pin the Extension</h3>
<p>Click the puzzle piece icon in the Chrome toolbar, find "Tab Grouper Tutorial", and click the pin icon to keep it visible.</p>
<p>The extension icon will now appear directly in your toolbar.</p>
<h3 id="heading-step-4-test-the-extension">Step 4: Test the Extension</h3>
<h4 id="heading-test-1-open-multiple-tabs">Test 1: Open Multiple Tabs</h4>
<p>Open several tabs across a few domains so there's something to group:</p>
<ol>
<li><p><code>https://github.com/topics</code>, <code>https://github.com/trending</code>, <code>https://github.com/explore</code></p>
</li>
<li><p><code>https://www.youtube.com/</code> and <code>https://www.youtube.com/trending</code></p>
</li>
<li><p><code>https://stackoverflow.com/questions</code> and <code>https://stackoverflow.com/tags</code></p>
</li>
</ol>
<p>Have at least 7 tabs open.</p>
<h4 id="heading-test-2-group-the-tabs">Test 2: Group the Tabs</h4>
<p>Click the Tab Grouper extension icon. The popup should appear showing your open tab count (7 or more) and group count (probably 0).</p>
<p>Click <strong>"Group Tabs by Domain"</strong> and watch your tabs get organized into colored groups.</p>
<h4 id="heading-test-3-verify-groups">Test 3: Verify Groups</h4>
<p>After clicking the button, GitHub tabs should be grouped together with a label like "github.com" and a consistent color, and YouTube tabs similarly.</p>
<p>Click the extension icon again, the group count should now show 2, while the tab count stays the same.</p>
<h3 id="heading-step-5-debug-the-extension">Step 5: Debug the Extension</h3>
<p>If something doesn't work, Chrome's DevTools are your best friend.</p>
<p>To inspect the background script, go to <code>chrome://extensions/</code>, find your extension, and click the <strong>"service worker"</strong> link.</p>
<p>A DevTools console opens where you can look for the "Tab Grouper background script loaded!" message and any error output in red.</p>
<p>To inspect the popup, right-click the extension icon and select <strong>"Inspect popup"</strong>. This opens DevTools for the popup specifically — check the Console tab for any errors there.</p>
<p><strong>If nothing happens when you click the button</strong>, check the background script console for errors, confirm you have at least 2 tabs from the same domain, and verify the message is being sent (look in the popup console for any <code>sendMessage</code> failures).</p>
<p><strong>If tabs aren't grouping</strong>, double-check that you added the <code>tabs</code> and <code>tabGroups</code> permissions to <code>package.json</code> and reloaded the extension after saving.</p>
<p><strong>If you see "Extension cannot access chrome://..."</strong>, that's expected behavior — extensions can't interact with Chrome's internal pages and the code skips them intentionally.</p>
<h3 id="heading-step-6-hot-reloading">Step 6: Hot Reloading</h3>
<p>One of the benefits of Plasmo is hot reloading, which allows you to update code in a running app instantly without needing to restart it manually.</p>
<p>Open <code>popup.tsx</code>, change the header emoji from 🗂️ to 📁, and save.</p>
<p>The extension reloads automatically.</p>
<p>Click the icon and you'll see the updated emoji immediately.</p>
<p>Hot reloading is advantageous because it speeds up development by letting you see changes in real time.</p>
<p>You can change the emoji back afterward if you'd like to keep the extension consistent with the rest of the tutorial examples and screenshots.</p>
<h3 id="heading-step-7-test-edge-cases">Step 7: Test Edge Cases</h3>
<p>It's worth testing a few scenarios to make sure the extension handles them gracefully.</p>
<p>If you close all tabs except one and click "Group Tabs", nothing should happen. The extension requires at least two tabs from the same domain to form a group. Opening <code>chrome://extensions</code> and <code>chrome://settings</code> and then grouping should also do nothing, since those pages are filtered out.</p>
<p>If you have one tab from <code>reddit.com</code> and one from <code>freecodecamp.org</code>, each domain appearing only once, no groups should be created.</p>
<h3 id="heading-step-8-production-build">Step 8: Production Build</h3>
<p>When you're ready to share your extension, run:</p>
<pre><code class="language-bash">pnpm run build
</code></pre>
<p>This creates a production-optimized version in <code>build/chrome-mv3-prod</code>, minified JavaScript, no development-only code, and smaller file size.</p>
<p>To verify the production build, go to <code>chrome://extensions/</code>, remove the development version, click "Load unpacked", and select <code>build/chrome-mv3-prod</code>. Test thoroughly before publishing.</p>
<p>The extension is lightweight (under 100 KB), only runs when you click the button, and has no background processes when idle.</p>
<h2 id="heading-next-steps-and-extension-ideas">Next Steps and Extension Ideas</h2>
<p>Congratulations on building your first Chrome extension!</p>
<p>You now have a working tool that groups tabs by domain with one click, shows live statistics about open tabs and groups, and is built on modern tooling: TypeScript, React, and Plasmo following Chrome extension best practices.</p>
<p>The extension is a solid foundation. Here are some ideas for where to take it next.</p>
<h3 id="heading-1-auto-grouping">1. Auto-Grouping</h3>
<p>Instead of requiring a button click, you could automatically group new tabs as they're opened. You'd listen for the <code>chrome.tabs.onCreated</code> event in <code>background.ts</code> and trigger <code>groupTabsByDomain()</code> with a short delay to let the page URL load:</p>
<pre><code class="language-typescript">// In background.ts
chrome.tabs.onCreated.addListener(async (tab) =&gt; {
  // Wait a bit for the URL to load
  setTimeout(() =&gt; {
    groupTabsByDomain()
  }, 2000)
})
</code></pre>
<p>This gets into event listeners, asynchronous timing, and thinking carefully about when to fire — a good next step for understanding how background scripts can be more proactive.</p>
<h3 id="heading-2-keyboard-shortcuts">2. Keyboard Shortcuts</h3>
<p>You can trigger grouping without even opening the popup by adding a keyboard shortcut. Add a <code>commands</code> section to the manifest in <code>package.json</code>:</p>
<pre><code class="language-json">"manifest": {
  "commands": {
    "group-tabs": {
      "suggested_key": {
        "default": "Ctrl+Shift+G",
        "mac": "Command+Shift+G"
      },
      "description": "Group tabs by domain"
    }
  }
}
</code></pre>
<p>Then listen for the command in <code>background.ts</code>:</p>
<pre><code class="language-typescript">chrome.commands.onCommand.addListener((command) =&gt; {
  if (command === "group-tabs") {
    groupTabsByDomain()
  }
})
</code></pre>
<h3 id="heading-3-category-based-grouping">3. Category-Based Grouping</h3>
<p>Rather than grouping by raw domain, you could group by category — putting GitHub, Stack Overflow, and npm together in a "Dev" group, for instance:</p>
<pre><code class="language-typescript">const categories = {
  social: ["facebook.com", "twitter.com", "instagram.com"],
  shopping: ["amazon.com", "ebay.com", "etsy.com"],
  dev: ["github.com", "stackoverflow.com", "npmjs.com"]
}

function getCategoryForDomain(domain: string): string {
  for (const [category, domains] of Object.entries(categories)) {
    if (domains.includes(domain)) {
      return category
    }
  }
  return "other"
}
</code></pre>
<h3 id="heading-4-options-page">4. Options Page</h3>
<p>Plasmo makes it trivial to add a settings page by creating an <code>options.tsx</code> file.</p>
<p>This is where you'd let users toggle auto-grouping, choose between domain and category mode, or configure their own category mappings.</p>
<p>It's a good introduction to the Chrome Storage API and persisting user preferences.</p>
<pre><code class="language-tsx">function OptionsPage() {
  return (
    &lt;div&gt;
      &lt;h1&gt;Tab Grouper Settings&lt;/h1&gt;
      &lt;label&gt;
        &lt;input type="checkbox" /&gt;
        Enable auto-grouping
      &lt;/label&gt;
      &lt;label&gt;
        &lt;input type="checkbox" /&gt;
        Group by category instead of domain
      &lt;/label&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h3 id="heading-5-tab-age-tracking">5. Tab Age Tracking</h3>
<p>You could track when each tab was created and surface tabs that have been sitting untouched for a week or more, a nice way to encourage tab hygiene:</p>
<pre><code class="language-typescript">// Track tab creation times
const tabCreationTimes = new Map&lt;number, number&gt;()

chrome.tabs.onCreated.addListener((tab) =&gt; {
  if (tab.id) {
    tabCreationTimes.set(tab.id, Date.now())
  }
})

// Find old tabs (e.g., &gt; 7 days)
function getOldTabs(): chrome.tabs.Tab[] {
  const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000)
  return tabs.filter(tab =&gt; {
    const created = tabCreationTimes.get(tab.id!)
    return created &amp;&amp; created &lt; sevenDaysAgo
  })
}
</code></pre>
<h3 id="heading-6-search-within-groups">6. Search Within Groups</h3>
<p>A search bar in the popup would let users filter their open tabs by title, making it easy to jump to a specific tab:</p>
<pre><code class="language-tsx">const [searchQuery, setSearchQuery] = useState("")

const filteredTabs = tabs.filter(tab =&gt;
  tab.title?.toLowerCase().includes(searchQuery.toLowerCase())
)
</code></pre>
<h3 id="heading-7-exportimport-groups">7. Export/Import Groups</h3>
<p>You could let users save their current tab groups to a JSON file and restore them later. Useful for preserving a working session across restarts:</p>
<pre><code class="language-typescript">// Export
async function exportGroups() {
  const groups = await chrome.tabGroups.query({})
  const data = JSON.stringify(groups)
  const blob = new Blob([data], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  chrome.downloads.download({ url, filename: 'tab-groups.json' })
}

// Import
async function importGroups(file: File) {
  const text = await file.text()
  const groups = JSON.parse(text)
  // Restore groups...
}
</code></pre>
<h3 id="heading-8-group-statistics-dashboard">8. Group Statistics Dashboard</h3>
<p>An expanded popup could show browsing analytics, total tabs opened today, most-visited domain, and more:</p>
<pre><code class="language-tsx">function Statistics() {
  const [stats, setStats] = useState({
    totalTabs: 0,
    totalGroups: 0,
    mostUsedDomain: "",
    tabsToday: 0
  })

  return (
    &lt;div&gt;
      &lt;h3&gt;Browsing Statistics&lt;/h3&gt;
      &lt;p&gt;Total tabs opened today: {stats.tabsToday}&lt;/p&gt;
      &lt;p&gt;Most visited domain: {stats.mostUsedDomain}&lt;/p&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2 id="heading-learning-resources">Learning Resources</h2>
<p>If you want to go deeper, the <a href="https://developer.chrome.com/docs/extensions/">official Chrome Extension docs</a> are excellent and cover every API in detail.</p>
<p>The <a href="https://github.com/GoogleChrome/chrome-extensions-samples">Chrome Extension Samples repository</a> on GitHub has dozens of real examples to learn from. For Plasmo-specific questions, the <a href="https://docs.plasmo.com/">Plasmo documentation</a> and <a href="https://github.com/PlasmoHQ/examples">example repository</a> are the best starting points, and the community is active on <a href="https://www.plasmo.com/community">Plasmo Discord</a>.</p>
<p>The <a href="https://react.dev/">React docs</a> and <a href="https://www.typescriptlang.org/docs/">TypeScript docs</a> are worth bookmarking as reference material, and the <a href="https://react-typescript-cheatsheet.netlify.app/">React TypeScript Cheatsheet</a> is handy when you're unsure about specific type patterns.</p>
<p>For community support, Stack Overflow's <code>chrome-extension</code> tag is well-monitored, and r/chrome_extensions on Reddit is a friendly place to ask questions.</p>
<h2 id="heading-deploying-to-chrome-web-store">Deploying to Chrome Web Store</h2>
<p>Now that you've built and tested your extension, here's how to publish it and share it with the world.</p>
<h3 id="heading-what-youll-need">What You'll Need</h3>
<p>Before you can publish, you'll need a completed and tested extension, a Google account, a $5 USD one-time developer registration fee, and some store assets such as icons, screenshots, and a written description.</p>
<p>The $5 fee is a one-time charge (not annual) that Google uses to verify developer identity and reduce spam. It covers unlimited extension submissions and is processed immediately via Google Payments.</p>
<h3 id="heading-step-1-create-a-production-build">Step 1: Create a Production Build</h3>
<p>Build your extension for production if you didn't do this before:</p>
<pre><code class="language-bash">cd tab-grouper-tutorial
npm run build
</code></pre>
<p>This creates an optimized version in <code>build/chrome-mv3-prod/</code>. The production build minifies JavaScript and CSS for a smaller file size, strips out development-only code and console logs, and optimizes assets for faster loading.</p>
<p>Before uploading, load <code>build/chrome-mv3-prod/</code> as an unpacked extension and test all features one more time to confirm nothing broke in the build process.</p>
<h3 id="heading-step-2-create-store-assets">Step 2: Create Store Assets</h3>
<h4 id="heading-extension-icons">Extension Icons</h4>
<p>You'll need icons in three sizes: <strong>128×128 pixels</strong> for the main store listing (required), <strong>48×48</strong> for the extension management page, and <strong>16×16</strong> for use as a favicon.</p>
<p>All should be PNG files with transparent backgrounds. Keep the design simple and recognizable at small sizes. Avoid putting text in the 16×16 version.</p>
<p><a href="https://figma.com">Figma</a> is free and works well for this, as does <a href="https://canva.com">Canva</a> or <a href="https://gimp.org">GIMP</a>.</p>
<h4 id="heading-screenshots">Screenshots</h4>
<p>Upload between 1 and 5 screenshots at either 1280×800 or 640×400 pixels (PNG or JPEG).</p>
<p>Show the extension in actual use rather than mockups. The popup with statistics, tabs being grouped, and the before/after state all work well.</p>
<p>Adding annotations to highlight key features helps users understand what they're looking at.</p>
<h4 id="heading-promotional-images-optional">Promotional Images (Optional)</h4>
<p>If you want to be featured on the store, you can also upload a small tile (440×280), large tile (920×680), and marquee image (1400×560). These are only needed if Google chooses to promote your extension.</p>
<h4 id="heading-demo-video-optional">Demo Video (Optional)</h4>
<p>A short YouTube video (30–60 seconds) showing the extension in action can significantly increase conversions. Link to it in your store listing.</p>
<h3 id="heading-step-3-write-your-store-listing">Step 3: Write Your Store Listing</h3>
<p><strong>Extension Name</strong> (45 character limit): Be clear and descriptive. "Tab Grouper - Organize Tabs by Domain" works well. Avoid keyword stuffing or excessive punctuation.</p>
<p><strong>Summary</strong> (132 character limit): This is what appears in search results. Lead with what the extension does: "Automatically organize browser tabs by domain. One-click grouping keeps your workspace clean and productive."</p>
<p><strong>Detailed Description</strong> (16,000 character limit): Start with what the extension does, list features clearly, explain how to use it, address privacy, and provide contact information. Here's a template you can adapt:</p>
<pre><code class="language-markdown">## What is Tab Grouper?

Tab Grouper automatically organizes your browser tabs by grouping them based on their website domain. No more hunting through dozens of tabs - everything is neatly organized.

## Features

- ✅ One-click tab grouping
- ✅ Automatic color-coding by domain
- ✅ Real-time statistics
- ✅ Works with all websites
- ✅ Lightweight and fast

## How to Use

1. Click the Tab Grouper icon in your toolbar
2. Click "Group Tabs by Domain"
3. Your tabs are instantly organized

## Why You Need This

If you regularly have numerous tabs open, finding the right one can waste valuable time. Tab Grouper solves this by automatically organizing tabs into colored groups, making navigation quick and straightforward.

## Privacy

This extension does not collect any personal data. It only accesses tab information locally to perform grouping. No data is sent to external servers.

## Support

Found a bug or have a suggestion? Contact us at support@example.com
</code></pre>
<p><strong>Category</strong>: Choose <strong>Productivity</strong> for Tab Grouper. You can add additional languages later if you want to localize the listing.</p>
<h3 id="heading-step-4-register-as-a-chrome-web-store-developer">Step 4: Register as a Chrome Web Store Developer</h3>
<p>Go to the <a href="https://chrome.google.com/webstore/devconsole">Chrome Web Store Developer Dashboard</a>, sign in with your Google account, accept the Developer Agreement, and pay the $5 registration fee. Your account is activated within minutes.</p>
<h3 id="heading-step-5-submit-your-extension">Step 5: Submit Your Extension</h3>
<p>In the Developer Dashboard, click <strong>"New Item"</strong> and upload your extension. You can either manually zip the <code>build/chrome-mv3-prod/</code> folder or use Plasmo's package command:</p>
<pre><code class="language-bash"># Option 1: Manual zip
cd build/chrome-mv3-prod
zip -r ../../tab-grouper.zip .

# Option 2: Use Plasmo package command
cd tab-grouper-tutorial
npm run package
</code></pre>
<p>Once uploaded, fill in all four sections of the store listing form: <strong>Product details</strong> (name, summary, description, category, language), <strong>Graphic assets</strong> (icon and screenshots), <strong>Privacy practices</strong> (see below), and <strong>Distribution</strong> (visibility, regions, pricing).</p>
<h4 id="heading-single-purpose-description">Single Purpose Description</h4>
<p>Chrome requires each extension to have a single, clearly stated purpose. For Tab Grouper: "This extension organizes browser tabs by grouping them based on their domain name, helping users manage multiple open tabs efficiently."</p>
<h4 id="heading-permission-justification">Permission Justification</h4>
<p>You'll need to justify each permission you declared. For <code>tabs</code>: "The tabs permission is required to read tab URLs and titles in order to group them by domain." For <code>tabGroups</code>: "The tabGroups permission is required to create and manage tab groups for organization."</p>
<h4 id="heading-privacy-policy">Privacy Policy</h4>
<p>Even though Tab Grouper doesn't collect personal data, Chrome may require a privacy policy. Host one on GitHub Pages or your personal website and link to it. Here's a minimal template:</p>
<pre><code class="language-markdown"># Privacy Policy for Tab Grouper

## Data Collection
Tab Grouper does not collect, store, or transmit any personal data.

## Permissions
- **tabs**: Used only to read tab URLs for grouping purposes
- **tabGroups**: Used only to create and manage tab groups

## Local Processing
All tab grouping happens locally in your browser. No data is sent to external servers.

## Contact
For questions: your-email@example.com

Last updated: [Current Date]
</code></pre>
<h3 id="heading-step-6-submit-for-review">Step 6: Submit for Review</h3>
<p>Before clicking submit, run through this checklist:</p>
<ul>
<li><p>Production build tested thoroughly</p>
</li>
<li><p>All store assets uploaded (icon + at least one screenshot)</p>
</li>
<li><p>Description is clear and accurate</p>
</li>
<li><p>Permissions are justified</p>
</li>
<li><p>Privacy policy is linked</p>
</li>
<li><p>Extension name is descriptive</p>
</li>
</ul>
<p>When you're ready, click <strong>"Submit for review"</strong>, confirm your details, and click <strong>"Publish"</strong>. Your extension enters the review queue.</p>
<h3 id="heading-step-7-the-review-process">Step 7: The Review Process</h3>
<p>Google typically reviews extensions within 1–3 business days for straightforward submissions, though complex extensions or first submissions can take up to a week. Reviewers check that the extension works as described, that permissions are justified, that there's no malicious code, and that the listing complies with Chrome Web Store policies.</p>
<p>You can track your status in the Developer Dashboard: Pending review → In review → Approved or Rejected. If rejected, Google will email you specific reasons and instructions for resubmitting.</p>
<p>The most common rejection reasons are insufficient permission justification, misleading descriptions, missing privacy policies, and requesting more permissions than necessary. Address each point in the rejection email, update your submission, and resubmit.</p>
<h3 id="heading-step-8-after-approval">Step 8: After Approval</h3>
<p>Once approved, your extension is live at <code>https://chrome.google.com/webstore/detail/[extension-id]</code>. Share the link on social media, write a blog post, post to Reddit (r/chrome, r/chrome_extensions), or submit to Product Hunt to drive installs.</p>
<p>The Developer Dashboard gives you ongoing analytics — total and weekly installs, reviews and ratings, impressions, and uninstall counts. Check it regularly, especially in the first week. Respond to reviews (particularly negative ones), thank users for positive feedback, and use reported bugs to prioritize future updates.</p>
<h3 id="heading-step-9-publishing-updates">Step 9: Publishing Updates</h3>
<p>When you fix bugs or add features, bump the version number in <code>package.json</code> (following <a href="https://semver.org/">Semantic Versioning</a> — patch for bug fixes, minor for new features, major for breaking changes), run <code>npm run build</code>, and upload the new package through the Developer Dashboard's <strong>Package</strong> tab. Updates are typically reviewed faster than initial submissions, often within 24 hours.</p>
<h3 id="heading-step-10-managing-your-extension-long-term">Step 10: Managing Your Extension Long-Term</h3>
<p>The Chrome Web Store provides built-in analytics, but you can also add Google Analytics if you need more detail.</p>
<p>For user support, an email address in the description or a GitHub issues page both work well. As you add features, keep the description updated and maintain a changelog so users know what changed and when. Responding to user questions and reviews goes a long way toward building a loyal base of users who'll recommend the extension to others.</p>
<h3 id="heading-troubleshooting-common-publishing-issues">Troubleshooting Common Publishing Issues</h3>
<p><strong>"Package is invalid" on upload</strong>: Make sure you zipped the contents of <code>build/chrome-mv3-prod/</code> rather than the folder itself, and verify the generated <code>manifest.json</code> is valid JSON.</p>
<p><strong>Rejection: Permissions Not Justified</strong>: In the "Permission justification" field, be specific about which feature requires each permission and what would break without it.</p>
<p><strong>Rejection: Single Purpose Unclear</strong>: Rewrite the single purpose description to focus on one main function, stated plainly.</p>
<p><strong>Low installation rate after launch</strong>: Poor screenshots are often the culprit — they're the first thing most users look at. Make sure they clearly show the extension solving a real problem. Building even a small number of early reviews also makes a big difference to new visitors.</p>
<h3 id="heading-alternative-distribution">Alternative Distribution</h3>
<p>The Chrome Web Store is the right choice for most public extensions. If you're building an internal tool, an <strong>Unlisted</strong> extension (accessible only via direct link, not searchable) is a good option.</p>
<p>If you need to restrict it to users in a specific Google Workspace organization, a <strong>Private</strong> extension is available for that. Self-hosting and sideloading is possible but requires users to enable Developer Mode manually, so it's only practical for very technical audiences.</p>
<h2 id="heading-congratulations">Congratulations!</h2>
<p>You've gone from an empty folder to a live Chrome extension on the Web Store. Along the way you learned how extensions are structured, how background scripts and popups communicate, how Chrome's tab APIs work, and how to navigate the publishing process end to end.</p>
<p>More than any specific API or configuration detail, the most important thing you've built is a mental model for how extensions work and that transfers directly to any extension idea you want to build next.</p>
<p>Keep building, keep learning, and keep shipping!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Context Hub (chub) to Build a Companion Relevance Engine
 ]]>
                </title>
                <description>
                    <![CDATA[ Large language models can write code quickly, but they still misremember APIs, miss version-specific details, and forget what they learned at the end of a session. That is the problem Context Hub is t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-context-hub-chub-to-build-a-companion-relevance-engine/</link>
                <guid isPermaLink="false">69e299d0fd22b8ad6276817b</guid>
                
                    <category>
                        <![CDATA[ context-hub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ search ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nataraj Sundar ]]>
                </dc:creator>
                <pubDate>Fri, 17 Apr 2026 20:36:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/14f9768e-436d-4c7e-b86c-3d380e821354.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Large language models can write code quickly, but they still misremember APIs, miss version-specific details, and forget what they learned at the end of a session.</p>
<p>That is the problem Context Hub is trying to solve.</p>
<p>Context Hub (<code>chub</code>) gives coding agents curated, versioned documentation and skills that they can search and fetch through a CLI. It also gives them two learning loops: local annotations for agent memory and feedback for maintainers.</p>
<p>In this tutorial, you'll learn how the official <code>chub</code> workflow works, how Context Hub organizes docs and skills, how annotations and feedback create a memory loop, and how to build a <a href="https://github.com/natarajsundar/context-hub-relevance-engine/">companion relevance engine</a> that improves retrieval without breaking the upstream content model.</p>
<p>This tutorial uses two public repositories side by side:</p>
<ul>
<li><p>the official upstream project: <a href="https://github.com/andrewyng/context-hub">andrewyng/context-hub</a></p>
</li>
<li><p>the companion implementation for this article: <a href="https://github.com/natarajsundar/context-hub-relevance-engine/">natarajsundar/context-hub-relevance-engine</a></p>
</li>
</ul>
<p>I've also opened a corresponding upstream pull request from my fork to the main project. If you want to track that work from the article, use the upstream pull request list filtered by author: <a href="https://github.com/andrewyng/context-hub/pulls?q=is%3Apr+author%3Anatarajsundar">andrewyng/context-hub pull requests by <code>natarajsundar</code></a>.</p>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>By the end of this tutorial, you'll have:</p>
<ul>
<li><p>a clear mental model for how Context Hub works</p>
</li>
<li><p>a working local install of the official <code>chub</code> CLI</p>
</li>
<li><p>a repeatable workflow for search, fetch, annotations, and feedback</p>
</li>
<li><p>a companion repo that adds an additive reranking layer on top of a Context-Hub-style content tree</p>
</li>
<li><p>a small benchmark and local comparison UI you can run end to end</p>
</li>
<li><p>a clear bridge between the companion repo and the smaller upstream PR</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>Node.js 18 or newer</p>
</li>
<li><p>npm</p>
</li>
<li><p>comfort with the terminal</p>
</li>
<li><p>basic familiarity with Markdown</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-to-understand-context-hub">How to Understand Context Hub</a></p>
</li>
<li><p><a href="#heading-how-to-understand-the-official-repo-the-companion-repo-and-the-upstream-pr">How to Understand the Official Repo, the Companion Repo, and the Upstream PR</a></p>
</li>
<li><p><a href="#heading-how-to-install-and-use-the-official-cli">How to Install and Use the Official CLI</a></p>
</li>
<li><p><a href="#heading-how-to-understand-docs-skills-and-the-content-layout">How to Understand Docs, Skills, and the Content Layout</a></p>
</li>
<li><p><a href="#heading-how-to-use-incremental-fetch-and-layered-sources">How to Use Incremental Fetch and Layered Sources</a></p>
</li>
<li><p><a href="#heading-how-to-use-annotations-and-feedback-to-create-a-memory-loop">How to Use Annotations and Feedback to Create a Memory Loop</a></p>
</li>
<li><p><a href="#heading-how-to-see-where-relevance-still-misses">How to See Where Relevance Still Misses</a></p>
</li>
<li><p><a href="#heading-how-the-companion-relevance-engine-improves-retrieval">How the Companion Relevance Engine Improves Retrieval</a></p>
</li>
<li><p><a href="#heading-how-to-run-the-companion-repo-end-to-end">How to Run the Companion Repo End to End</a></p>
</li>
<li><p><a href="#heading-how-to-read-the-benchmark-honestly">How to Read the Benchmark Honestly</a></p>
</li>
<li><p><a href="#heading-how-to-connect-the-companion-repo-to-the-upstream-pr">How to Connect the Companion Repo to the Upstream PR</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-sources">Sources</a></p>
</li>
</ol>
<h2 id="heading-how-to-understand-context-hub">How to Understand Context Hub</h2>
<p>Context Hub is easiest to understand as a workflow for turning fast-moving documentation into a reliable input for coding agents.</p>
<p>Instead of asking an agent to rely on whatever it remembers from training data, you give it a predictable contract:</p>
<ol>
<li><p>search for the right entry</p>
</li>
<li><p>fetch the right doc or skill</p>
</li>
<li><p>write code against that curated content</p>
</li>
<li><p>save local lessons as annotations</p>
</li>
<li><p>send doc-quality feedback back to maintainers</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/09d75c85-fbb0-4c9a-86d5-8acdff4e1abf.png" alt="Diagram showing the Context Hub loop from developer prompt to agent search and fetch, then annotations and maintainer feedback." style="display: block;" width="1654" height="307" loading="lazy">

<p>That system boundary matters.</p>
<p>It makes the agent easier to audit, easier to improve, and easier to extend. It also keeps the interface small enough that you can reason about where the failures happen. If the agent still misses the answer, you can ask whether the problem happened during search, fetch, context selection, or generation.</p>
<h2 id="heading-how-to-understand-the-official-repo-the-companion-repo-and-the-upstream-pr">How to Understand the Official Repo, the Companion repo, and the Upstream PR</h2>
<p>This tutorial is intentionally split across two codebases and one contribution path.</p>
<p>The official upstream project, <a href="https://github.com/andrewyng/context-hub">andrewyng/context-hub</a>, is the source of truth for the real CLI, the content model, and the documented workflows. That's the codebase you should use to learn how <code>chub</code> works today.</p>
<p>The companion repository, <a href="https://github.com/natarajsundar/context-hub-relevance-engine/">natarajsundar/context-hub-relevance-engine</a>, is where the relevant ideas in this article are made concrete. It's a companion implementation, not a replacement product. Its job is to make retrieval tradeoffs visible, measurable, and easy to run locally.</p>
<p>The upstream PR is the bridge between those two worlds. The companion repo is where you can iterate faster on benchmarks, reranking, and the comparison UI. The upstream PR is where the smallest reviewable slices can be proposed back to the main project. You can track that thread here: <a href="https://github.com/andrewyng/context-hub/pulls?q=is%3Apr+author%3Anatarajsundar">upstream PR search filtered by author</a>.</p>
<p>That three-part framing keeps the article honest:</p>
<ul>
<li><p><strong>use the upstream repo</strong> to understand the current system</p>
</li>
<li><p><strong>use the companion repo</strong> to explore relevant improvements end to end</p>
</li>
<li><p><strong>use the upstream PR</strong> to show how a larger idea can be broken into reviewable pieces</p>
</li>
</ul>
<h2 id="heading-how-to-install-and-use-the-official-cli">How to Install and Use the Official CLI</h2>
<p>The official quick start is intentionally small.</p>
<pre><code class="language-bash">npm install -g @aisuite/chub
</code></pre>
<p>Once the CLI is installed, you can search for what is available and fetch a specific entry:</p>
<pre><code class="language-bash">chub search openai
chub get openai/chat --lang py
</code></pre>
<p>That's the happy path, but it helps to think through the request flow.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/c5ff71d4-5e51-48b8-bbd3-fc2aafa93b9d.png" alt="Sequence diagram showing the developer asking the agent for current docs, the agent calling chub search and chub get, and the CLI fetching docs from the registry." style="display: block;" width="1416" height="683" loading="lazy">

<p>In practice, the most useful detail is that the CLI is designed for the <strong>agent</strong> to use, not just for the human to use by hand.</p>
<p>That's why the upstream CLI also ships a <code>get-api-docs</code> skill. For example, if you use Claude Code, you can copy the skill into your local project like this:</p>
<pre><code class="language-bash">mkdir -p .claude/skills
cp $(npm root -g)/@aisuite/chub/skills/get-api-docs/SKILL.md \
  .claude/skills/get-api-docs.md
</code></pre>
<p>That step teaches the agent a retrieval habit:</p>
<blockquote>
<p>Before you write code against a third-party SDK or API, use <code>chub</code> instead of guessing.</p>
</blockquote>
<p>That behavioral rule is often as important as the docs themselves.</p>
<h2 id="heading-how-to-understand-docs-skills-and-the-content-layout">How to Understand Docs, Skills, and the Content Layout</h2>
<p>Context Hub separates content into two categories:</p>
<ul>
<li><p><strong>docs</strong>, which answer “what should the agent know?”</p>
</li>
<li><p><strong>skills</strong>, which answer “how should the agent behave?”</p>
</li>
</ul>
<p>That distinction makes the content model easier to scale. Docs can be versioned and language-specific. Skills can stay short and operational.</p>
<p>The directory structure is also predictable. The content guide organizes entries by author, then by <code>docs</code> or <code>skills</code>, then by entry name.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/3ac72bc2-c869-4e2e-9294-d63b35991135.png" alt="Diagram showing the content tree from author to docs and skills, with DOC.md and SKILL.md feeding a build step that emits registry and search artifacts." style="display: block;" width="674" height="739" loading="lazy">

<p>A small example looks like this:</p>
<pre><code class="language-text">author/docs/payments/python/DOC.md
author/docs/payments/python/references/errors.md
author/skills/login-flows/SKILL.md
</code></pre>
<p>This is one of the reasons Context Hub is easy to work with.</p>
<p>The shape of the content is plain Markdown, the main entry file is predictable, and the build output is inspectable. You don't have to reverse engineer a hidden prompt layer to figure out what the agent is reading.</p>
<h2 id="heading-how-to-use-incremental-fetch-and-layered-sources">How to Use Incremental Fetch and Layered Sources</h2>
<p>One of the best design choices in Context Hub is that it doesn't force you to inject every file into the model on every request.</p>
<p>Instead, the entry file gives you the overview, and the reference files hold the deeper material.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/88d80a48-c991-495a-af25-14a0c0ac9868.png" alt="Diagram showing how chub get can fetch just the main entry file, a specific reference file, or the full entry directory." style="display: block;" width="592" height="460" loading="lazy">

<p>That lets you fetch content in progressively larger slices.</p>
<pre><code class="language-bash">chub get stripe/webhooks --lang py
chub get stripe/webhooks --lang py --file references/raw-body.md
chub get stripe/webhooks --lang py --full
</code></pre>
<p>This is a token-budget feature as much as it is a documentation feature. A good agent should first load the overview, decide what part of the task matters, and only then fetch the specific supporting file.</p>
<p>Context Hub also supports layered sources. You can merge public content with your own local build output through <code>~/.chub/config.yaml</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/67465254-7a7c-4cfc-b9f0-9e94d8c3e2f3.png" alt="Diagram showing community, official, and local team sources merging into one search surface for chub search and chub get." style="display: block;" width="774" height="460" loading="lazy">

<p>A minimal configuration looks like this:</p>
<pre><code class="language-yaml">sources:
  - name: community
    url: https://cdn.aichub.org/v1
  - name: my-team
    path: /opt/team-docs/dist
</code></pre>
<p>That means you can keep public docs in one lane and team-specific runbooks in another lane while still giving the agent one search surface.</p>
<h2 id="heading-how-to-use-annotations-and-feedback-to-create-a-memory-loop">How to Use Annotations and Feedback to Create a Memory Loop</h2>
<p>Context Hub has two different improvement channels.</p>
<p>Annotations are local. They help your agent remember what worked last time. Feedback is shared. It helps maintainers improve the docs for everyone.</p>
<p>That distinction matters because not every lesson belongs in the shared registry. Some lessons are environment-specific. Others point to content quality issues that should be fixed centrally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/a8514430-08cb-4085-8047-64df25c603c7.png" alt="Diagram showing the agent fetch/write cycle, then branching to local annotations or maintainer feedback before the next task." style="display: block;" width="808" height="798" loading="lazy">

<p>Here is what local memory looks like in practice:</p>
<pre><code class="language-bash">chub annotate stripe/webhooks \
  "Remember: Flask request.data must stay raw for Stripe signature verification."
</code></pre>
<p>And here's the feedback path:</p>
<pre><code class="language-bash">chub feedback stripe/webhooks up
</code></pre>
<p>That loop is simple, but it's one of the most important ideas in the project. It turns a one-off debugging lesson into either persistent local memory or a signal that the shared docs need to improve.</p>
<h2 id="heading-how-to-see-where-relevance-still-misses">How to See Where Relevance Still Misses</h2>
<p>The upstream project already has a real ranking story. It uses BM25 and lexical rescue so that package-like identifiers, exact tokens, and fuzzy matches still have a chance to surface.</p>
<p>That is a strong baseline.</p>
<p>But developer queries are often much messier than package names.</p>
<p>People search for:</p>
<ul>
<li><p><code>rrf</code></p>
</li>
<li><p><code>signin</code></p>
</li>
<li><p><code>pg vector</code></p>
</li>
<li><p><code>hnsw</code></p>
</li>
<li><p><code>raw body stripe</code></p>
</li>
</ul>
<p>Those aren't “bad” queries. They're realistic shorthand.</p>
<p>And they expose an opportunity in the content model itself: many of the exact answers live in reference files such as <code>references/rrf.md</code>, <code>references/raw-body.md</code>, and <code>references/hnsw.md</code>.</p>
<p>So the question is not whether the current search works at all. It clearly does. The better question is this:</p>
<blockquote>
<p>How can you improve retrieval without breaking the content contract that already makes Context Hub useful?</p>
</blockquote>
<p>The answer in the companion repo is to keep the current model and add a reranking layer on top of it.</p>
<h2 id="heading-how-the-companion-relevance-engine-improves-retrieval">How the Companion Relevance Engine Improves Retrieval</h2>
<p>The companion repository in this article is <a href="https://github.com/natarajsundar/context-hub-relevance-engine/"><code>context-hub-relevance-engine</code></a>.</p>
<p>It keeps the same broad ideas that make Context Hub attractive:</p>
<ul>
<li><p>plain Markdown content</p>
</li>
<li><p><code>DOC.md</code> and <code>SKILL.md</code> entry points</p>
</li>
<li><p>build artifacts you can inspect</p>
</li>
<li><p>local annotations and feedback</p>
</li>
<li><p>progressive fetch behavior</p>
</li>
</ul>
<p>Then it adds one new build artifact: <code>signals.json</code>.</p>
<p>At build time, the engine extracts extra signals such as:</p>
<ul>
<li><p>headings from the main file</p>
</li>
<li><p>titles and tokens from reference files</p>
</li>
<li><p>language and version metadata</p>
</li>
<li><p>source metadata and freshness</p>
</li>
<li><p>annotation overlap</p>
</li>
<li><p>feedback priors</p>
</li>
</ul>
<p>The first pass stays cheap and transparent. The reranker only runs after the baseline has done its work.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/2ed2dadb-8fff-41ee-904b-0792cafcf744.png" alt="Diagram showing the relevance pipeline from query to BM25 and lexical rescue, then synonym expansion, candidate set building, reranking signals, and final results." style="display: block;" width="1399" height="541" loading="lazy">

<p>That approach matters for two reasons.</p>
<p>First, it's additive. You don't have to redesign the content tree.</p>
<p>Second, it's measurable. You can define concrete failure modes, fix them one by one, and run the same benchmark every time you change the scorer.</p>
<h2 id="heading-how-to-run-the-companion-repo-end-to-end">How to Run the Companion Repo End to End</h2>
<p>Open the repository on <a href="https://github.com/natarajsundar/context-hub-relevance-engine/">GitHub</a>, clone it using GitHub’s normal clone flow, and then run the commands below from the project root.</p>
<pre><code class="language-bash">cd context-hub-relevance-engine
npm install
npm run build
npm test
</code></pre>
<p>The repository has no third-party runtime dependencies, so <code>npm install</code> is mostly there to keep the workflow familiar. The main commands are all plain Node scripts.</p>
<h3 id="heading-how-to-reproduce-a-baseline-miss">How to Reproduce a Baseline Miss</h3>
<p>Start with the query <code>rrf</code>.</p>
<pre><code class="language-bash">node bin/chub-lab.mjs search rrf --mode baseline --lang python
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">No results.
</code></pre>
<p>Now run the improved mode.</p>
<pre><code class="language-bash">node bin/chub-lab.mjs search rrf --mode improved --lang python
</code></pre>
<p>Expected top result:</p>
<pre><code class="language-text">langchain/retrievers [doc] score=320.24
  Composable retrieval patterns for hybrid search, parent documents, query expansion, and reranking.
</code></pre>
<p>That win happens because the improved mode looks beyond the top-level entry description. It also sees the reference file title <code>rrf</code>, the related terms from query expansion, and the broader token overlap in the extracted signals.</p>
<h3 id="heading-how-to-reproduce-a-workflow-intent-win">How to Reproduce a Workflow-intent Win</h3>
<p>Try a sign-in query.</p>
<pre><code class="language-bash">node bin/chub-lab.mjs search signin --mode baseline
node bin/chub-lab.mjs search signin --mode improved
</code></pre>
<p>The baseline misses. The improved mode returns <code>playwright-community/login-flows</code> because the reranker treats <code>signin</code>, <code>sign in</code>, <code>login</code>, and <code>authentication</code> as related intent.</p>
<h3 id="heading-how-to-test-the-memory-loop">How to Test the Memory Loop</h3>
<p>Write a local note:</p>
<pre><code class="language-bash">node bin/chub-lab.mjs annotate stripe/webhooks \
  "Remember: Flask request.data must stay raw for Stripe signature verification."
</code></pre>
<p>Then fetch the doc:</p>
<pre><code class="language-bash">node bin/chub-lab.mjs get stripe/webhooks --lang python
</code></pre>
<p>You will see the main doc content, the list of available reference files, and the appended annotation.</p>
<p>That's the behavior you want from an agent memory loop: learn once, reuse many times.</p>
<h3 id="heading-how-to-run-the-benchmark">How to Run the Benchmark</h3>
<p>Start from an empty store:</p>
<pre><code class="language-bash">npm run reset-store
node bin/chub-lab.mjs evaluate
</code></pre>
<p>The included synthetic stress set reports the following summary with an empty store:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Top-1 Accuracy</th>
<th>MRR</th>
</tr>
</thead>
<tbody><tr>
<td>baseline</td>
<td>0.333</td>
<td>0.333</td>
</tr>
<tr>
<td>improved</td>
<td>1.000</td>
<td>1.000</td>
</tr>
</tbody></table>
<p>You can also seed the store and rerun the evaluation:</p>
<pre><code class="language-bash">npm run seed-demo
node bin/chub-lab.mjs evaluate
</code></pre>
<p>That demonstrates how annotations and feedback can push relevant entries even higher when the query overlaps with the agent’s own history.</p>
<h3 id="heading-how-to-launch-the-local-comparison-ui">How to Launch the Local Comparison UI</h3>
<pre><code class="language-bash">npm run serve
</code></pre>
<p>Then open <code>http://localhost:8787</code> in your browser.</p>
<p>The UI lets you compare baseline and improved retrieval, inspect stored annotations and feedback, rebuild the local artifacts, and rerun the benchmark from one place.</p>
<h2 id="heading-how-to-read-the-benchmark-honestly">How to Read the Benchmark Honestly</h2>
<p>The benchmark in this repo is intentionally small.</p>
<p>That is a feature, not a flaw.</p>
<p>The point is not to claim universal search quality. The point is to make a handful of realistic failure modes easy to reproduce:</p>
<ul>
<li><p>acronym queries</p>
</li>
<li><p>shorthand workflow queries</p>
</li>
<li><p>reference-file topic queries</p>
</li>
<li><p>memory-aware reranking</p>
</li>
</ul>
<p>That keeps the evaluation honest.</p>
<p>If a future scoring change breaks <code>rrf</code>, <code>signin</code>, or <code>raw body stripe</code>, you'll know immediately. And if you add a stronger dataset later, you can keep these tests as regression guards.</p>
<p>The benchmark files included in the repo are:</p>
<ul>
<li><p><code>demo/benchmark.json</code></p>
</li>
<li><p><code>docs/benchmark-empty-store.json</code></p>
</li>
<li><p><code>docs/benchmark-seeded-store.json</code></p>
</li>
<li><p><code>docs/relevance-improvement-plan.md</code></p>
</li>
</ul>
<h2 id="heading-how-to-connect-the-companion-repo-to-the-upstream-pr">How to Connect the Companion Repo to the Upstream PR</h2>
<p>A good companion repo is broad enough to explore ideas quickly. A good upstream PR is narrow enough to review.</p>
<p>That's why the two shouldn't be identical.</p>
<p>The companion repository is where you can keep the full relevance story together:</p>
<ul>
<li><p>the local comparison UI</p>
</li>
<li><p>the synthetic benchmark</p>
</li>
<li><p>the richer reranking signals</p>
</li>
<li><p>the debug and explain surfaces</p>
</li>
<li><p>the documentation that walks through tradeoffs end to end</p>
</li>
</ul>
<p>The upstream PR should be smaller and more surgical. In practice, that usually means proposing the most reviewable slices first, such as:</p>
<ol>
<li><p>reference-file signal extraction</p>
</li>
<li><p>explainable score output for debugging</p>
</li>
<li><p>a lightweight benchmark fixture format</p>
</li>
<li><p>one additive reranking hook behind a flag</p>
</li>
</ol>
<p>That keeps the main repository maintainable while still letting the article and companion repo tell the full engineering story. The upstream thread for this work lives here: <a href="https://github.com/andrewyng/context-hub/pulls?q=is%3Apr+author%3Anatarajsundar">andrewyng/context-hub pull requests by <code>natarajsundar</code></a>.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>What makes Context Hub interesting is not just that it stores documentation. It gives you a clear system boundary for improving coding agents.</p>
<p>You can inspect what the agent reads. You can decide when it should retrieve. You can layer public and private sources. You can persist local lessons. And you can improve ranking without tearing the whole model apart.</p>
<p>The companion relevance engine shows how to keep what already works, make one part of the system measurably better, and package the result in a way other developers can run, inspect, and extend. The upstream PR, in turn, shows how to turn a broad idea into smaller pieces that are realistic to review in the main project.</p>
<h2 id="heading-diagram-attribution">Diagram Attribution</h2>
<p>All diagrams used in this article were created by the author specifically for this tutorial and its companion repository.</p>
<h2 id="heading-sources">Sources</h2>
<ul>
<li><p><a href="https://github.com/andrewyng/context-hub">Context Hub repository</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/README.md">Context Hub README</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/cli/README.md">Context Hub CLI README</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/docs/cli-reference.md">Context Hub CLI reference</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/docs/content-guide.md">Context Hub content guide</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/docs/byod-guide.md">Context Hub bring-your-own-docs guide</a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/blob/main/docs/feedback-and-annotations.md">Context Hub feedback and annotations guide</a></p>
</li>
<li><p><a href="https://github.com/natarajsundar/context-hub-relevance-engine/">Companion repository: <code>context-hub-relevance-engine</code></a></p>
</li>
<li><p><a href="https://github.com/andrewyng/context-hub/pulls?q=is%3Apr+author%3Anatarajsundar">Upstream pull request search filtered by author</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Set Up OpenClaw and Design an A2A Plugin Bridge ]]>
                </title>
                <description>
                    <![CDATA[ OpenClaw is getting attention because it turns a popular AI idea into something you can actually run yourself. Instead of opening one more browser tab, you run a Gateway on your own machine or server  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/openclaw-a2a-plugin-architecture-guide/</link>
                <guid isPermaLink="false">69d542ca5da14bc70e7c1559</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nataraj Sundar ]]>
                </dc:creator>
                <pubDate>Tue, 07 Apr 2026 17:45:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4be03b02-d128-49e9-afcb-fea0f771e746.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>OpenClaw is getting attention because it turns a popular AI idea into something you can actually run yourself. Instead of opening one more browser tab, you run a Gateway on your own machine or server and connect it to communication tools you already use.</p>
<p>That matters because OpenClaw is self-hosted, multi-channel, open source, and built around agent workflows such as sessions, tools, plugins, and multi-agent routing. It feels less like a toy chatbot and more like an operator-controlled agent runtime.</p>
<p>In this guide, you'll do three things. First, you'll learn what OpenClaw is and why developers are paying attention to it. Second, you'll get it running the beginner-friendly way through the dashboard. Third, you'll walk through an original design contribution: a proposed OpenClaw-to-A2A plugin architecture and a <a href="https://github.com/natarajsundar/openclaw-a2a-secure-agent-runtime"><code>proof-of-concept</code></a> relay that shows how OpenClaw’s session model could map to the A2A protocol.</p>
<p>That last part is important, so I want to frame it carefully. The A2A integration in this article is <strong>not</strong> presented as a built-in OpenClaw feature. It's a documented architecture proposal built on top of the extension points OpenClaw already exposes.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This guide is beginner-friendly for OpenClaw itself, but it assumes a few basics so you can follow the architecture and proof-of-concept sections comfortably.</p>
<p>Before you continue, you should be familiar with:</p>
<ul>
<li><p>Basic JavaScript or Node.js (reading and running scripts)</p>
</li>
<li><p>How HTTP APIs work (requests, responses, JSON payloads)</p>
</li>
<li><p>Using a terminal to run commands</p>
</li>
<li><p>High-level concepts like services, APIs, or microservices</p>
</li>
</ul>
<p>You don't need prior experience with OpenClaw or A2A. The setup steps walk through everything you need to get started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-openclaw-is">What OpenClaw Is</a></p>
</li>
<li><p><a href="#heading-why-openclaw-is-getting-so-much-attention">Why OpenClaw Is Getting So Much Attention</a></p>
</li>
<li><p><a href="#heading-what-the-a2a-protocol-is">What the A2A Protocol Is</a></p>
</li>
<li><p><a href="#heading-how-openclaw-and-a2a-relate">How OpenClaw and A2A Relate</a></p>
</li>
<li><p><a href="#heading-what-you-need-before-you-start">What You Need Before You Start</a></p>
</li>
<li><p><a href="#heading-step-1-install-openclaw">Install OpenClaw</a></p>
</li>
<li><p><a href="#heading-step-2-run-the-onboarding-wizard">Run the Onboarding Wizard</a></p>
</li>
<li><p><a href="#heading-step-3-check-the-gateway-and-open-the-dashboard">Check the Gateway and Open the Dashboard</a></p>
</li>
<li><p><a href="#heading-step-4-use-openclaw-as-a-private-coding-assistant">Use OpenClaw as a Private Coding Assistant</a></p>
</li>
<li><p><a href="#heading-step-5-understand-multi-agent-routing">Understand Multi Agent Routing</a></p>
</li>
<li><p><a href="#heading-where-a2a-could-fit-later">Where A2A Could Fit Later</a></p>
</li>
<li><p><a href="#heading-a-proposed-openclaw-to-a2a-plugin-architecture">A Proposed OpenClaw to A2A Plugin Architecture</a></p>
</li>
<li><p><a href="#heading-build-the-proof-of-concept-relay">Build the Proof of Concept Relay</a></p>
</li>
<li><p><a href="#heading-how-the-proof-of-concept-maps-to-a-real-openclaw-plugin">How the Proof of Concept Maps to a Real OpenClaw Plugin</a></p>
</li>
<li><p><a href="#heading-security-notes-before-you-go-further">Security Notes Before You Go Further</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ol>
<h2 id="heading-what-openclaw-is">What OpenClaw Is</h2>
<p>According to the <a href="https://docs.openclaw.ai/">official docs</a>, OpenClaw is a self-hosted gateway that connects chat apps like WhatsApp, Telegram, Discord, iMessage, and a browser dashboard to AI agents.</p>
<p>That wording is useful because it tells you where OpenClaw sits in the stack. It's not just a model wrapper. It's a Gateway that handles sessions, routing, and app connections, while agents, tools, plugins, and providers do the actual work.</p>
<p>Here is the simplest mental model:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/ad5f3295-8fdf-4f9c-8488-f69808850295.png" alt="Diagram showing OpenClaw architecture where multiple chat apps and a browser dashboard connect to a central Gateway, which routes requests to different agents that use model providers and tools." style="display: block;" width="1097" height="462" loading="lazy">

<p>If you're new to the project, this is the practical way to think about it:</p>
<ul>
<li><p>your chat apps are the front door</p>
</li>
<li><p>the Gateway is the traffic and control layer</p>
</li>
<li><p>the agent is the reasoning layer</p>
</li>
<li><p>the model provider and tools are what let the agent actually do work</p>
</li>
</ul>
<p>That's one reason OpenClaw feels different from a normal browser-only assistant.</p>
<h2 id="heading-why-developers-are-paying-attention-to-openclaw">Why Developers Are Paying Attention to OpenClaw</h2>
<p>OpenClaw is getting a lot of attention for a few reasons.</p>
<p>The first reason is control. The docs position OpenClaw as self-hosted and multi-channel, which means you can run it on your own machine or server instead of depending on a fully hosted assistant.</p>
<p>The second reason is that OpenClaw already looks like an agent platform. The docs talk about sessions, plugins, tools, skills, multi-agent routing, and ACP-backed external coding harnesses. That's a much richer story than “ask a model a question in a web page.”</p>
<p>The third reason is workflow fit. A lot of people don't want another inbox. They want an assistant that can live in the tools they already check every day.</p>
<p>There's also a broader industry trend behind the hype. Developers are actively looking for ways to connect multiple agents and multiple tools without giving up visibility into what's happening. OpenClaw sits directly in that conversation.</p>
<h2 id="heading-what-the-a2a-protocol-is">What the A2A Protocol Is</h2>
<p>A2A, short for Agent2Agent, is an open protocol for communication between agent systems. The <a href="https://a2a-protocol.org/latest/specification/">A2A specification</a> says its purpose is to help independent agent systems discover each other, negotiate interaction modes, manage collaborative tasks, and exchange information without exposing internal memory, tools, or proprietary logic.</p>
<p>That last point matters. A2A is about interoperability between agent systems, not about exposing all of one agent's internals to another.</p>
<p>A2A introduces a few core concepts that are worth learning early:</p>
<ul>
<li><p><strong>Agent Card</strong>: a JSON description of the remote agent, its URL, skills, capabilities, and auth requirements</p>
</li>
<li><p><strong>Task</strong>: the main unit of remote work</p>
</li>
<li><p><strong>Artifact</strong>: the output of a task</p>
</li>
<li><p><strong>Context ID</strong>: a stable interaction boundary across multiple related turns</p>
</li>
</ul>
<p>A2A tasks follow a fairly clean lifecycle:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/3b5a43e8-dabd-45e3-bff1-0081e2b37e0d.png" alt="State diagram illustrating the A2A task lifecycle including submitted, working, input required, completed, failed, rejected, and canceled states.." style="display: block;" width="598" height="380" loading="lazy">

<p>The A2A docs also explain that A2A and MCP are complementary, not competing. A2A is for agent-to-agent collaboration. MCP is for agent-to-tool communication.</p>
<p>That distinction is useful when you compare A2A with OpenClaw, because OpenClaw already has strong local tool and session concepts.</p>
<h2 id="heading-how-openclaw-and-a2a-relate">How OpenClaw and A2A Relate</h2>
<p>OpenClaw and A2A are not the same thing, but they line up in interesting ways.</p>
<p>OpenClaw already documents several features that point in a multi-agent direction:</p>
<ul>
<li><p><a href="https://docs.openclaw.ai/concepts/multi-agent/">multi-agent routing</a> for multiple isolated agents in one running Gateway</p>
</li>
<li><p><a href="https://docs.openclaw.ai/concepts/session-tool/">session tools</a> such as <code>sessions_send</code> and <code>sessions_spawn</code></p>
</li>
<li><p>a <a href="https://docs.openclaw.ai/tools/plugin/">plugin system</a> that can register tools, HTTP routes, Gateway RPC methods, and background services</p>
</li>
<li><p><a href="https://docs.openclaw.ai/tools/acp-agents/">ACP support</a> and the <a href="https://docs.openclaw.ai/cli/acp"><code>openclaw acp</code> bridge</a> for external coding clients</p>
</li>
</ul>
<p>But it's still important to stay precise here.</p>
<p>OpenClaw documents ACP, plugins, and local multi-agent coordination today. The docs I checked do <strong>not</strong> describe native A2A support as a first-class built-in capability.</p>
<p>That means the honest claim is this:</p>
<p><strong>OpenClaw can be meaningfully connected to A2A in theory because the architectural pieces line up, but the A2A bridge still has to be built.</strong></p>
<h3 id="heading-acp-versus-a2a">ACP versus A2A</h3>
<p>ACP and A2A solve different problems.</p>
<p>ACP in OpenClaw today is about bridging an IDE or coding client to a Gateway-backed session.</p>
<p>A2A is about one agent system talking to another agent system across a protocol boundary.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/9790f239-528c-422f-bbc5-3e82c7f1a171.png" alt="Diagram showing A2A interaction where an OpenClaw agent communicates through a plugin to discover a remote agent via an Agent Card and send tasks for execution." style="display: block;" width="1232" height="233" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/c4d4279b-3099-4c1b-92b6-3eaf817a6e84.png" alt="Diagram showing ACP flow where an IDE or coding client connects through an OpenClaw ACP bridge to a Gateway-backed session." style="display: block;" width="1179" height="215" loading="lazy">

<p>That difference is one reason I prefer the phrase <strong>plugin bridge</strong> here instead of <strong>native A2A support</strong>.</p>
<h2 id="heading-what-you-need-before-you-start">What You Need Before You Start</h2>
<p>The easiest first run does <strong>not</strong> require WhatsApp, Telegram, or Discord.</p>
<p>The OpenClaw onboarding docs say the fastest first chat is the dashboard. That makes this a much more approachable beginner setup.</p>
<p>Before you start, you'll need:</p>
<ol>
<li><p>Node 24 if possible, or Node 22.16+ for compatibility</p>
</li>
<li><p>an API key for the model provider you want to use</p>
</li>
<li><p>If you're on Windows, WSL2 is the recommended path for the full experience. Native Windows works for core CLI and Gateway flows, but the docs call out caveats and position WSL2 as the more stable setup.</p>
</li>
<li><p>about five minutes for the first dashboard-based run</p>
</li>
</ol>
<h2 id="heading-step-1-install-openclaw">Step 1: Install OpenClaw</h2>
<p>The official getting-started page recommends the installer script.</p>
<p>On macOS, Linux, or WSL2, run:</p>
<pre><code class="language-bash">curl -fsSL https://openclaw.ai/install.sh | bash
</code></pre>
<p>On Windows PowerShell, the docs show this:</p>
<pre><code class="language-powershell">iwr -useb https://openclaw.ai/install.ps1 | iex
</code></pre>
<p>If you're on Windows, the platform docs recommend installing WSL2 first:</p>
<pre><code class="language-powershell">wsl --install
</code></pre>
<p>Then open Ubuntu and continue with the Linux commands there.</p>
<h2 id="heading-step-2-run-the-onboarding-wizard">Step 2: Run the Onboarding Wizard</h2>
<p>Once the CLI is installed, run the onboarding wizard.</p>
<pre><code class="language-bash">openclaw onboard --install-daemon
</code></pre>
<p>The onboarding wizard is the recommended path in the docs. It configures auth, gateway settings, optional channels, skills, and workspace defaults in one guided flow.</p>
<p>The most beginner-friendly choice is to keep the first run simple. Don't worry about chat apps yet. Get the local Gateway working first.</p>
<h2 id="heading-step-3-check-the-gateway-and-open-the-dashboard">Step 3: Check the Gateway and Open the Dashboard</h2>
<p>After onboarding, verify that the Gateway is running.</p>
<pre><code class="language-bash">openclaw gateway status
</code></pre>
<p>Then open the dashboard:</p>
<pre><code class="language-bash">openclaw dashboard
</code></pre>
<p>The docs call this the fastest first chat because it avoids channel setup. It's also the safest way to start, because the dashboard is local and the OpenClaw docs clearly say the Control UI is an admin surface and should not be exposed publicly.</p>
<p>The beginner setup flow looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/eab78250-65d6-4d97-be3d-bf7167b9099e.png" alt="Sequence diagram showing OpenClaw setup flow from installation and onboarding to starting the Gateway and opening the dashboard for the first chat." style="display: block;" width="1200" height="635" loading="lazy">

<p>If you can chat in the dashboard, your day-zero setup is working.</p>
<h2 id="heading-step-4-use-openclaw-as-a-private-coding-assistant">Step 4: Use OpenClaw as a Private Coding Assistant</h2>
<p>The best first use case is not to drop OpenClaw into a public group chat.</p>
<p>Use it as a private coding assistant in the dashboard.</p>
<p>For example, try a prompt like this:</p>
<blockquote>
<p>I am building a small Node.js utility that reads Markdown files and generates a table of contents. Turn this idea into a project plan, a README outline, and the first five implementation tasks.</p>
</blockquote>
<p>That kind of prompt is ideal for a first run because it gives you something concrete back right away.</p>
<p>You can also use it to:</p>
<ol>
<li><p>turn rough notes into a plan,</p>
</li>
<li><p>summarize a bug report into action items,</p>
</li>
<li><p>draft a README,</p>
</li>
<li><p>propose a folder structure, or</p>
</li>
<li><p>write a safe first implementation checklist.</p>
</li>
</ol>
<p>That is already enough to make OpenClaw useful before you touch any advanced protocol work.</p>
<h2 id="heading-step-5-understand-multi-agent-routing">Step 5: Understand Multi Agent Routing</h2>
<p>Once the basic setup is working, it helps to understand OpenClaw’s local multi-agent model.</p>
<p>The docs describe multi-agent routing as a way to run multiple isolated agents in one Gateway, with separate workspaces, state directories, and sessions.</p>
<p>That means you can imagine setups like this:</p>
<ul>
<li><p>a personal assistant</p>
</li>
<li><p>a coding assistant</p>
</li>
<li><p>a research assistant</p>
</li>
<li><p>an alerts assistant</p>
</li>
</ul>
<p>OpenClaw already has a model for that:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/c640a7c4-0421-4513-a2c2-658916504e3b.png" alt="Diagram illustrating OpenClaw multi-agent routing where incoming messages are matched to different agents such as main, coding, and alerts, each with separate sessions." style="display: block;" width="663" height="588" loading="lazy">

<p>You don't need to set this up on day one.</p>
<p>But it matters for the A2A discussion, because once you understand how OpenClaw routes work between local agents, it becomes much easier to think about routing work to <strong>remote</strong> agents through a protocol like A2A.</p>
<h2 id="heading-where-a2a-could-fit-later">Where A2A Could Fit Later</h2>
<p>A2A could fit into OpenClaw in two broad ways.</p>
<h3 id="heading-option-1-openclaw-as-an-a2a-client">Option 1: OpenClaw as an A2A Client</h3>
<p>In this model, OpenClaw stays your personal edge assistant.</p>
<p>It receives a request from the dashboard or a chat app, decides the task needs a specialist, discovers a remote A2A agent through an Agent Card, sends the task, waits for updates or artifacts, and translates the result back into a normal OpenClaw reply.</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/99a2e611-54ac-4c0f-8f8f-c1ce3246bb96.png" alt="Diagram showing OpenClaw acting as an A2A client, delegating tasks from a local session to a remote agent via an Agent Card and returning results to the user." style="display: block;" width="1548" height="945" loading="lazy">

<p>This is the cleaner story for a personal assistant. OpenClaw stays the front door, and A2A becomes a delegation path behind the scenes.</p>
<h3 id="heading-option-2-openclaw-as-an-a2a-server">Option 2: OpenClaw as an A2A Server</h3>
<p>In this model, OpenClaw exposes some of its own capabilities to other agents.</p>
<p>A plugin could theoretically publish an A2A Agent Card, advertise a narrow skill set, accept A2A tasks, and map those tasks into OpenClaw sessions or sub-agent runs.</p>
<p>That's technically plausible because the plugin system can register HTTP routes, tools, Gateway methods, and background services.</p>
<p>It's also the riskier direction for a personal assistant, which is why I think <strong>client-first</strong> is the right starting point.</p>
<h2 id="heading-a-proposed-openclaw-to-a2a-plugin-architecture">A Proposed OpenClaw to A2A Plugin Architecture</h2>
<p>This section is my original contribution in the article.</p>
<p>I think the cleanest first architecture is <strong>not</strong> a full bidirectional bridge. It's a narrow outbound delegation plugin that lets OpenClaw call a small allowlist of remote A2A agents.</p>
<p>The design goal is simple:</p>
<p><strong>Reuse OpenClaw for user-facing conversations and local tool access, but use A2A only when a remote specialist agent is the best place to do the work.</strong></p>
<p>Here is the architecture I would start with:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/e88f06dd-f108-48b2-a9ee-b74eac6b733b.png" alt="Architecture diagram of an OpenClaw-to-A2A plugin showing components such as delegation tool, policy engine, Agent Card cache, session-to-task mapper, task poller, and remote A2A agent." style="display: block;" width="1548" height="945" loading="lazy">

<h3 id="heading-why-this-design-is-a-good-fit-for-openclaw">Why This Design is a Good Fit for OpenClaw</h3>
<p>This proposal is grounded in extension points OpenClaw already documents.</p>
<p>A plugin can register:</p>
<ul>
<li><p>an <strong>agent tool</strong> for delegation,</p>
</li>
<li><p>a <strong>Gateway method</strong> for health and diagnostics,</p>
</li>
<li><p>an <strong>HTTP route</strong> for future callbacks or webhook verification, and</p>
</li>
<li><p>a <strong>background service</strong> for cache warming, task subscriptions, or cleanup.</p>
</li>
</ul>
<p>That means the bridge doesn't have to modify OpenClaw core to be credible.</p>
<h3 id="heading-the-mapping-table">The Mapping Table</h3>
<p>The most important design decision is how to map OpenClaw’s session model to A2A’s task model.</p>
<p>Here is the mapping I recommend:</p>
<table>
<thead>
<tr>
<th>OpenClaw concept</th>
<th>A2A concept</th>
<th>Why this mapping works</th>
</tr>
</thead>
<tbody><tr>
<td><code>sessionKey</code></td>
<td><code>contextId</code></td>
<td>A single OpenClaw conversation should keep a stable remote context across related delegated turns</td>
</tr>
<tr>
<td>one delegated remote call</td>
<td>one <code>Task</code></td>
<td>each remote specialization request becomes a discrete unit of work</td>
</tr>
<tr>
<td>plugin tool call</td>
<td><code>SendMessage</code></td>
<td>the delegation tool is the natural point where the local agent crosses the protocol boundary</td>
</tr>
<tr>
<td>remote output</td>
<td><code>Artifact</code></td>
<td>A2A wants task outputs returned as artifacts rather than chat-only replies</td>
</tr>
<tr>
<td>plugin HTTP route</td>
<td>callback or future push handler</td>
<td>gives you a place to verify webhooks if you later adopt async push</td>
</tr>
<tr>
<td>Gateway method</td>
<td>status endpoint</td>
<td>gives operators a direct way to inspect relay health without going through the model</td>
</tr>
<tr>
<td>background service</td>
<td>polling or cache work</td>
<td>keeps asynchronous and maintenance work out of the tool call path</td>
</tr>
</tbody></table>
<p>This is the key architectural claim in the article:</p>
<p><strong>Treat the OpenClaw session as the long-lived conversational boundary, and treat each remote A2A task as one delegated execution inside that boundary.</strong></p>
<p>That preserves both sides cleanly.</p>
<h3 id="heading-the-design-in-one-sentence">The Design in One Sentence</h3>
<p>The <code>a2a_delegate</code> tool should:</p>
<ol>
<li><p>resolve an allowlisted remote Agent Card,</p>
</li>
<li><p>reuse an existing A2A <code>contextId</code> for the current <code>sessionKey</code> when possible,</p>
</li>
<li><p>create a fresh remote <code>Task</code> for the new delegated turn,</p>
</li>
<li><p>normalize remote artifacts back into a simple local answer, and</p>
</li>
<li><p>never expose the whole OpenClaw Gateway directly to the public internet.</p>
</li>
</ol>
<p>I like this design because it is incremental, testable, and consistent with OpenClaw’s personal-assistant trust model.</p>
<h2 id="heading-build-the-proof-of-concept-relay">Build the Proof of Concept Relay</h2>
<p>To make the architecture concrete, I built a small proof-of-concept relay.</p>
<p><a href="https://github.com/natarajsundar/openclaw-a2a-secure-agent-runtime">https://github.com/natarajsundar/openclaw-a2a-secure-agent-runtime</a></p>
<p>It's intentionally small. It doesn't try to become a full production plugin. Instead, it proves the hardest conceptual part of the bridge: how to map one OpenClaw session to a reusable A2A context while creating a fresh A2A task per delegated turn.</p>
<p>Here's the repository layout:</p>
<pre><code class="language-plaintext">openclaw-a2a-secure-agent-runtime/
├── README.md
├── package.json
├── examples/
│   └── openclaw-plugin-entry.example.ts
├── src/
│   ├── a2a-client.mjs
│   ├── agent-card-cache.mjs
│   ├── demo.mjs
│   ├── mock-remote-agent.mjs
│   ├── openclaw-a2a-relay.mjs
│   ├── session-task-map.mjs
│   └── utils.mjs
└── test/
    └── relay.test.mjs
</code></pre>
<p>The PoC does six things:</p>
<ol>
<li><p>fetches a remote Agent Card from <code>/.well-known/agent-card.json</code>,</p>
</li>
<li><p>caches it with simple <code>ETag</code> revalidation,</p>
</li>
<li><p>records local <code>sessionKey</code> to remote <code>contextId</code> mappings,</p>
</li>
<li><p>sends an A2A <code>SendMessage</code> request,</p>
</li>
<li><p>polls <code>GetTask</code> until the task finishes, and</p>
</li>
<li><p>converts the remote artifact into a local text answer.</p>
</li>
</ol>
<h3 id="heading-run-the-demo">Run the Demo</h3>
<p>The repo uses only built-in Node.js modules.</p>
<pre><code class="language-shell">cd openclaw-a2a-secure-agent-runtime
npm run demo
</code></pre>
<p>The demo spins up a mock remote A2A server, delegates one task, delegates a second task from the <strong>same</strong> local session, and shows that the same remote <code>contextId</code> is reused.</p>
<h3 id="heading-the-core-relay-idea">The Core Relay Idea</h3>
<p>This is the important logic in plain English:</p>
<ol>
<li><p>look up the most recent remote mapping for the current OpenClaw <code>sessionKey</code></p>
</li>
<li><p>reuse the old <code>contextId</code> if one exists</p>
</li>
<li><p>create a fresh A2A <code>Task</code> for the new request</p>
</li>
<li><p>poll until that task becomes <code>TASK_STATE_COMPLETED</code></p>
</li>
<li><p>turn the returned artifact into a normal text result that OpenClaw can send back to the user</p>
</li>
</ol>
<p>That makes the bridge predictable.</p>
<p>Here's a shortened version of the relay logic:</p>
<pre><code class="language-js">const previous = await sessionTaskMap.latestForSession(sessionKey, remoteBaseUrl);
const contextId = previous?.contextId ?? crypto.randomUUID();

const sendResult = await client.sendMessage({
  text,
  contextId,
  metadata: {
    openclawSessionKey: sessionKey,
    requestedSkillId: skillId,
  },
});

let task = sendResult.task;
while (!isTerminalTaskState(task.status?.state)) {
  await sleep(pollIntervalMs);
  task = await client.getTask(task.id);
}

return {
  contextId,
  taskId: task.id,
  answer: taskArtifactsToText(task),
};
</code></pre>
<p>That's the heart of the design.</p>
<h3 id="heading-why-this-repo-is-a-useful-proof-of-concept">Why This Repo is a Useful Proof of Concept</h3>
<p>A lot of “integration” articles stay too abstract. This repo avoids that problem in three ways.</p>
<p>First, it makes the session-to-context mapping explicit.</p>
<p>Second, it includes a mock remote A2A agent so you can test the flow without needing a large external setup.</p>
<p>Third, it includes a test that checks the most important invariant: repeated delegations from one local OpenClaw session reuse the same A2A context.</p>
<p>That is the piece I most wanted to make concrete, because it is where architecture turns into implementation.</p>
<h2 id="heading-how-the-proof-of-concept-maps-to-a-real-openclaw-plugin">How the Proof of Concept Maps to a Real OpenClaw Plugin</h2>
<p>The proof of concept is the relay core.</p>
<p>A real OpenClaw plugin would wrap that relay with four extension surfaces that the OpenClaw docs already describe.</p>
<h3 id="heading-1-a-delegation-tool">1: A Delegation Tool</h3>
<p>This is the main entry point.</p>
<p>A plugin would register an optional tool like <code>a2a_delegate</code> so the local agent can explicitly choose to delegate work.</p>
<p>That tool should be optional, not always-on, because remote delegation is a side effect and should be easy to disable.</p>
<h3 id="heading-2-a-gateway-method-for-diagnostics">2: A Gateway Method for Diagnostics</h3>
<p>A method like <code>a2a.status</code> would let you inspect whether the relay is healthy, which remote cards are cached, and whether any tasks are still being tracked.</p>
<p>That is much better than asking the model to “tell me if the bridge is healthy.”</p>
<h3 id="heading-3-a-plugin-http-route">3: A Plugin HTTP Route</h3>
<p>You may not need this on day one.</p>
<p>But once you move beyond polling and want push-style callbacks or webhook verification, a plugin route gives you the right boundary for that work.</p>
<h3 id="heading-4-a-background-service">4: A Background Service</h3>
<p>A small service is a clean place to do cache warming, cleanup, or later subscription handling.</p>
<p>That keeps the tool path focused on delegation instead of maintenance work.</p>
<p>If I were turning this into a real plugin package, I would sequence the work in this order:</p>
<ol>
<li><p>wrap the relay in <code>registerTool</code>,</p>
</li>
<li><p>add a small config schema with an allowlist of remote agents,</p>
</li>
<li><p>add <code>a2a.status</code>,</p>
</li>
<li><p>keep polling as the first async model,</p>
</li>
<li><p>add a callback route only if a real use case needs it.</p>
</li>
</ol>
<p>That is the most practical path from theory to a real extension.</p>
<p>I tested the relay flow locally with the mock remote agent and confirmed that repeated delegations from the same local session reused the same remote <code>contextId</code>.</p>
<h2 id="heading-security-notes-before-you-go-further">Security Notes Before You Go Further</h2>
<p>This is the section you should not skip.</p>
<p>The OpenClaw security docs explicitly say the project assumes a <strong>personal assistant</strong> trust model: one trusted operator boundary per Gateway. They also say a shared Gateway for mutually untrusted or adversarial users is not the supported boundary model.</p>
<p>That has a direct consequence for A2A.</p>
<p>A2A is designed for communication across agent systems and organizational boundaries. That is powerful, but it is also a different threat model from a single private OpenClaw deployment.</p>
<p>So the safer design is <strong>not</strong> this:</p>
<ul>
<li><p>expose your personal OpenClaw Gateway publicly,</p>
</li>
<li><p>let arbitrary remote agents reach it,</p>
</li>
<li><p>and hope the tool boundaries are enough.</p>
</li>
</ul>
<p>The safer design is closer to this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/694ca88d5ac09a5d68c63854/5ab4460a-6c00-4880-a29c-ddc1db00b5fa.png" alt="Diagram illustrating separation between a private OpenClaw deployment and an external A2A interoperability boundary, highlighting secure delegation through a controlled relay." style="display: block;" width="1227" height="422" loading="lazy">

<p>This diagram shows two separate trust boundaries.</p>
<p>On the left is your <strong>private OpenClaw deployment</strong>. This includes your Gateway, your sessions, your workspace, and any credentials or tools your agent can access. This boundary is designed for a single trusted operator.</p>
<p>On the right is the <strong>external A2A ecosystem</strong>, where remote agents live. These agents may belong to other teams or organizations and operate under different security assumptions.</p>
<p>The key idea is that communication between these two sides should happen through a <strong>controlled relay layer</strong>, not by directly exposing your OpenClaw Gateway. The relay enforces allowlists, limits what data is sent out, and ensures that remote agents cannot directly access your local tools or state.</p>
<p>This separation lets you experiment with agent interoperability while keeping your personal assistant environment safe.</p>
<p>In plain English, keep your personal assistant boundary private.</p>
<p>If you experiment with A2A, treat that as a <strong>separate exposure boundary</strong> with its own allowlists, auth, and operational controls.</p>
<p>That is why the proof-of-concept relay in this article starts with an explicit remote allowlist.</p>
<h3 id="heading-why-this-design-and-not-the-other-one">Why This Design and Not the Other One?</h3>
<p>A natural question is why this article proposes an <strong>outbound-only A2A bridge first</strong>, instead of immediately building a full bidirectional or server-style integration.</p>
<p>The short answer is that OpenClaw’s current design is centered around a <strong>personal assistant trust boundary</strong>, where one operator controls the Gateway, sessions, and tools. Introducing external agents into that environment requires careful control over what is exposed.</p>
<p>Starting with outbound delegation gives you a safer and more incremental path.</p>
<p>Outbound-only first means:</p>
<ul>
<li><p>preserving the personal-assistant trust boundary, so your local OpenClaw deployment remains private and operator-controlled</p>
</li>
<li><p>avoiding exposing the OpenClaw Gateway as a public A2A server before you have strong auth, policy, and monitoring in place</p>
</li>
<li><p>allowing you to test remote delegation patterns (Agent Cards, tasks, artifacts) without committing to full interoperability complexity</p>
</li>
<li><p>keeping OpenClaw as the user-facing control plane, while remote agents act as optional specialists</p>
</li>
</ul>
<p>This approach follows a common systems design pattern: start with <strong>controlled outbound integration</strong>, validate behavior and constraints, and only then consider expanding to inbound or bidirectional communication.</p>
<p>In practice, this means you can experiment with A2A safely, learn how the models fit together, and evolve the system without introducing unnecessary risk early on.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>OpenClaw is worth learning because it gives you a self-hosted assistant that can live in the communication tools you already use.</p>
<p>The simplest beginner path is still the right one:</p>
<ol>
<li><p>install it,</p>
</li>
<li><p>run onboarding,</p>
</li>
<li><p>check the Gateway,</p>
</li>
<li><p>open the dashboard,</p>
</li>
<li><p>try one private workflow.</p>
</li>
</ol>
<p>That is already a real end-to-end setup.</p>
<p>A2A belongs in the conversation because it gives you a credible way to connect OpenClaw to remote specialist agents later.</p>
<p>But the most important thing in this article isn't the buzzword. It's the boundary design.</p>
<p>If you keep OpenClaw as the private user-facing edge and use a narrow plugin bridge for outbound delegation, the OpenClaw session model and the A2A task model can fit together cleanly.</p>
<p>That is the architectural idea I wanted to make concrete here.</p>
<h3 id="heading-diagram-attribution">Diagram Attribution</h3>
<p>All diagrams in this article were created by the author specifically for this guide.</p>
<h2 id="heading-further-reading">Further Reading</h2>
<ul>
<li><p><a href="https://docs.openclaw.ai/">OpenClaw docs home</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/start/getting-started">OpenClaw Getting Started</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/start/wizard">OpenClaw Onboarding Wizard</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/concepts/multi-agent/">OpenClaw Multi-Agent Routing</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/concepts/session-tool/">OpenClaw Session Tools</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/tools/plugin/">OpenClaw Plugin System</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/plugins/agent-tools">OpenClaw Plugin Agent Tools</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/cli/acp">OpenClaw ACP bridge</a></p>
</li>
<li><p><a href="https://docs.openclaw.ai/gateway/security">OpenClaw Security</a></p>
</li>
<li><p><a href="https://a2a-protocol.org/latest/specification/">A2A specification</a></p>
</li>
<li><p><a href="https://a2a-protocol.org/latest/topics/agent-discovery/">A2A Agent Discovery</a></p>
</li>
<li><p><a href="https://a2a-protocol.org/latest/topics/a2a-and-mcp/">A2A and MCP</a></p>
</li>
<li><p><a href="https://a2a-protocol.org/latest/definitions/">A2A protocol definition and schema</a></p>
</li>
<li><p><a href="https://a2a-protocol.org/latest/announcing-1.0/">A2A version 1.0 announcement</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
