diff --git a/README.md b/README.md
index 5f1a2e0..95cbb19 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,30 @@
-# x402-directory
-A curated, agent-maintained directory of x402 payment-enabled applications and endpoints. Browse, search, and discover services that accept x402 micropayments. Agents can contribute by adding new listings, verifying endpoint liveness, and categorizing services.
+# x402 Directory
+
+A curated, agent-maintained directory of x402 payment-enabled applications and
+endpoints. Browse, search, and discover services that accept x402 micropayments.
+Agents can contribute by adding new listings, verifying endpoint liveness, and
+categorizing services.
+
+## Category Taxonomy
+
+The directory ships with an initial taxonomy for x402 services:
+
+- AI/ML APIs
+- Data feeds
+- Compute
+- Storage
+- Content
+- Developer tools
+- Identity and auth
+
+Each listing belongs to one category. The app includes a categories index and
+individual category pages with filtered listings.
+
+## Run Locally
+
+```bash
+npm test
+npm run dev
+```
+
+Then open `http://localhost:4173`.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..6b7bf03
--- /dev/null
+++ b/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ x402 Directory
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1f67b3e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "x402-directory",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "node scripts/serve.mjs",
+ "test": "node --test tests/catalog.test.mjs"
+ }
+}
diff --git a/scripts/serve.mjs b/scripts/serve.mjs
new file mode 100644
index 0000000..d9f522a
--- /dev/null
+++ b/scripts/serve.mjs
@@ -0,0 +1,51 @@
+import { createReadStream, existsSync, statSync } from "node:fs";
+import { createServer } from "node:http";
+import { extname, join, normalize } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = normalize(fileURLToPath(new URL("..", import.meta.url)));
+const port = Number.parseInt(process.env.PORT ?? "4173", 10);
+
+const mimeTypes = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml"
+};
+
+function resolvePath(requestUrl) {
+ const url = new URL(requestUrl, `http://localhost:${port}`);
+ const pathname = decodeURIComponent(url.pathname);
+ const target = normalize(join(root, pathname === "/" ? "index.html" : pathname));
+
+ if (!target.startsWith(root)) {
+ return null;
+ }
+
+ if (!existsSync(target)) {
+ return join(root, "index.html");
+ }
+
+ return statSync(target).isDirectory() ? join(target, "index.html") : target;
+}
+
+const server = createServer((request, response) => {
+ const filePath = resolvePath(request.url ?? "/");
+
+ if (!filePath || !existsSync(filePath)) {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not found");
+ return;
+ }
+
+ response.writeHead(200, {
+ "Cache-Control": "no-store",
+ "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream"
+ });
+ createReadStream(filePath).pipe(response);
+});
+
+server.listen(port, () => {
+ console.log(`x402 Directory running at http://localhost:${port}`);
+});
diff --git a/src/app.js b/src/app.js
new file mode 100644
index 0000000..452ab31
--- /dev/null
+++ b/src/app.js
@@ -0,0 +1,173 @@
+import {
+ categories,
+ getCategory,
+ getCategoryCounts,
+ getListingsByCategory,
+ listings
+} from "./catalog.js";
+
+const app = document.querySelector("#app");
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function listingCard(listing) {
+ const category = getCategory(listing.category);
+ const status = listing.verified ? "Verified" : "Needs check";
+
+ return `
+
+
+
${escapeHtml(category?.name ?? "Uncategorized")}
+
${escapeHtml(listing.name)}
+
${escapeHtml(listing.description)}
+
+
+
+
Pricing
+ ${escapeHtml(listing.pricing)}
+
+
+
Status
+ ${status}
+
+
+
Last checked
+ ${escapeHtml(listing.lastChecked)}
+
+
+ Try endpoint
+
+ `;
+}
+
+function categoryCard(category) {
+ return `
+
+ ${category.count} listing${category.count === 1 ? "" : "s"}
+ ${escapeHtml(category.name)}
+ ${escapeHtml(category.description)}
+
+ `;
+}
+
+function renderHome() {
+ app.innerHTML = `
+
+ Agent-maintained x402 services
+ Discover payment-enabled endpoints by category.
+
+ Browse an initial taxonomy for x402 services and jump into filtered
+ category pages for AI, data, compute, storage, content, developer tools,
+ and identity services.
+
+
+
+
+
+
Categories
+
${categories.length} categories covering ${listings.length} starter listings.
+
+
+ ${getCategoryCounts().map(categoryCard).join("")}
+
+
+ `;
+}
+
+function renderCategories() {
+ app.innerHTML = `
+
+
+
Taxonomy
+
Browse by category
+
Each category page filters the directory to matching x402 services.
+
+
+ ${getCategoryCounts().map(categoryCard).join("")}
+
+
+ `;
+}
+
+function renderCategory(slug) {
+ const category = getCategory(slug);
+
+ if (!category) {
+ renderNotFound();
+ return;
+ }
+
+ const categoryListings = getListingsByCategory(slug);
+
+ app.innerHTML = `
+
+ Back to categories
+
+
${categoryListings.length} listing${categoryListings.length === 1 ? "" : "s"}
+
${escapeHtml(category.name)}
+
${escapeHtml(category.description)}
+
+
+ ${categoryListings.length ? categoryListings.map(listingCard).join("") : "
No listings yet.
"}
+
+
+ `;
+}
+
+function renderListings() {
+ app.innerHTML = `
+
+
+
${listings.length} starter listings
+
All x402 listings
+
Use category labels to understand where each endpoint fits.
+
+
+ ${listings.map(listingCard).join("")}
+
+
+ `;
+}
+
+function renderNotFound() {
+ app.innerHTML = `
+
+ 404
+ Category not found.
+ The requested category does not exist in the current taxonomy.
+ Browse categories
+
+ `;
+}
+
+function route() {
+ const hash = window.location.hash || "#/";
+ const [, section, slug] = hash.split("/");
+
+ if (!section) {
+ renderHome();
+ } else if (section === "categories" && slug) {
+ renderCategory(slug);
+ } else if (section === "categories") {
+ renderCategories();
+ } else if (section === "listings") {
+ renderListings();
+ } else {
+ renderNotFound();
+ }
+
+ app.focus({ preventScroll: true });
+}
+
+window.addEventListener("hashchange", route);
+route();
diff --git a/src/catalog.js b/src/catalog.js
new file mode 100644
index 0000000..a1777c7
--- /dev/null
+++ b/src/catalog.js
@@ -0,0 +1,139 @@
+export const categories = [
+ {
+ slug: "ai-ml-apis",
+ name: "AI/ML APIs",
+ description: "Inference, embeddings, agents, and model-powered services."
+ },
+ {
+ slug: "data-feeds",
+ name: "Data feeds",
+ description: "Paid access to live, historical, or specialized data streams."
+ },
+ {
+ slug: "compute",
+ name: "Compute",
+ description: "On-demand execution, jobs, inference workers, and serverless tasks."
+ },
+ {
+ slug: "storage",
+ name: "Storage",
+ description: "Files, blobs, retrieval endpoints, backups, and archival services."
+ },
+ {
+ slug: "content",
+ name: "Content",
+ description: "Articles, media, reports, documents, and gated creative assets."
+ },
+ {
+ slug: "developer-tools",
+ name: "Developer tools",
+ description: "APIs, SDKs, testing tools, monitoring, and automation utilities."
+ },
+ {
+ slug: "identity-auth",
+ name: "Identity and auth",
+ description: "Verification, profiles, credentials, sessions, and access control."
+ }
+];
+
+export const listings = [
+ {
+ id: "model-meter",
+ name: "Model Meter",
+ url: "https://example.com/model-meter",
+ category: "ai-ml-apis",
+ description: "x402-gated token usage estimates for common model providers.",
+ pricing: "$0.002 per request",
+ verified: true,
+ lastChecked: "2026-05-10"
+ },
+ {
+ id: "chain-snapshot-feed",
+ name: "Chain Snapshot Feed",
+ url: "https://example.com/chain-snapshot",
+ category: "data-feeds",
+ description: "Fresh chain metadata snapshots for agent workflows.",
+ pricing: "$0.01 per snapshot",
+ verified: true,
+ lastChecked: "2026-05-12"
+ },
+ {
+ id: "micro-render",
+ name: "Micro Render",
+ url: "https://example.com/micro-render",
+ category: "compute",
+ description: "Short-lived browser render jobs paid through x402.",
+ pricing: "$0.05 per render",
+ verified: false,
+ lastChecked: "2026-05-08"
+ },
+ {
+ id: "agent-archive",
+ name: "Agent Archive",
+ url: "https://example.com/agent-archive",
+ category: "storage",
+ description: "Pay-per-upload object storage for agent-generated artifacts.",
+ pricing: "$0.001 per MB",
+ verified: true,
+ lastChecked: "2026-05-11"
+ },
+ {
+ id: "research-briefs",
+ name: "Research Briefs",
+ url: "https://example.com/research-briefs",
+ category: "content",
+ description: "Gated market and technical briefs with x402 payment access.",
+ pricing: "$0.25 per brief",
+ verified: false,
+ lastChecked: "2026-05-05"
+ },
+ {
+ id: "x402-probe",
+ name: "x402 Probe",
+ url: "https://example.com/x402-probe",
+ category: "developer-tools",
+ description: "Endpoint liveness checks and payment handshake diagnostics.",
+ pricing: "$0.003 per check",
+ verified: true,
+ lastChecked: "2026-05-13"
+ },
+ {
+ id: "wallet-pass",
+ name: "Wallet Pass",
+ url: "https://example.com/wallet-pass",
+ category: "identity-auth",
+ description: "Wallet-bound access tokens for paid agent sessions.",
+ pricing: "$0.01 per session",
+ verified: false,
+ lastChecked: "2026-05-09"
+ }
+];
+
+export function getCategory(slug) {
+ return categories.find((category) => category.slug === slug);
+}
+
+export function getListingsByCategory(slug) {
+ return listings.filter((listing) => listing.category === slug);
+}
+
+export function getCategoryCounts() {
+ return categories.map((category) => ({
+ ...category,
+ count: getListingsByCategory(category.slug).length
+ }));
+}
+
+export function validateCatalog() {
+ const categorySlugs = new Set(categories.map((category) => category.slug));
+ const duplicateSlugs = categories
+ .map((category) => category.slug)
+ .filter((slug, index, allSlugs) => allSlugs.indexOf(slug) !== index);
+ const invalidListings = listings.filter((listing) => !categorySlugs.has(listing.category));
+
+ return {
+ valid: duplicateSlugs.length === 0 && invalidListings.length === 0,
+ duplicateSlugs,
+ invalidListings
+ };
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..c1c9c19
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,208 @@
+:root {
+ color: #17211d;
+ background: #f5f7f2;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+}
+
+a {
+ color: inherit;
+}
+
+.site-header {
+ align-items: center;
+ background: #ffffff;
+ border-bottom: 1px solid #dfe6dc;
+ display: flex;
+ justify-content: space-between;
+ min-height: 64px;
+ padding: 0 32px;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.brand {
+ font-size: 1.05rem;
+ font-weight: 800;
+ text-decoration: none;
+}
+
+nav {
+ display: flex;
+ gap: 18px;
+}
+
+nav a,
+.back-link {
+ color: #406051;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.app-shell {
+ margin: 0 auto;
+ max-width: 1120px;
+ padding: 48px 32px 72px;
+}
+
+.app-shell:focus {
+ outline: none;
+}
+
+.hero {
+ max-width: 780px;
+ padding: 40px 0 52px;
+}
+
+.hero h1,
+.section-heading h1 {
+ font-size: clamp(2rem, 6vw, 4.25rem);
+ line-height: 1;
+ margin: 0 0 18px;
+}
+
+.hero p,
+.section-heading p {
+ color: #55655d;
+ font-size: 1.05rem;
+ margin: 0;
+ max-width: 720px;
+}
+
+.eyebrow {
+ color: #2f6f4e;
+ font-size: 0.76rem;
+ font-weight: 800;
+ letter-spacing: 0;
+ margin: 0 0 10px;
+ text-transform: uppercase;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 28px;
+}
+
+.button {
+ align-items: center;
+ border: 1px solid #b8c8bd;
+ border-radius: 6px;
+ display: inline-flex;
+ font-weight: 800;
+ justify-content: center;
+ min-height: 42px;
+ padding: 0 16px;
+ text-decoration: none;
+}
+
+.button.primary {
+ background: #173c2b;
+ border-color: #173c2b;
+ color: #ffffff;
+}
+
+.section-heading {
+ margin-bottom: 24px;
+}
+
+.section-heading h2 {
+ font-size: 2rem;
+ margin: 0 0 8px;
+}
+
+.category-grid,
+.listing-grid {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+}
+
+.category-card,
+.listing-card {
+ background: #ffffff;
+ border: 1px solid #dfe6dc;
+ border-radius: 8px;
+ box-shadow: 0 18px 40px rgba(24, 42, 31, 0.06);
+}
+
+.category-card {
+ display: block;
+ min-height: 190px;
+ padding: 20px;
+ text-decoration: none;
+}
+
+.category-card span {
+ color: #2f6f4e;
+ display: block;
+ font-size: 0.82rem;
+ font-weight: 800;
+ margin-bottom: 18px;
+}
+
+.category-card h3,
+.listing-card h3 {
+ font-size: 1.25rem;
+ margin: 0 0 10px;
+}
+
+.category-card p,
+.listing-card p {
+ color: #55655d;
+ margin: 0;
+}
+
+.listing-card {
+ display: grid;
+ gap: 18px;
+ padding: 20px;
+}
+
+.listing-card dl {
+ border-top: 1px solid #edf1eb;
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding-top: 14px;
+}
+
+.listing-card div {
+ display: grid;
+ gap: 2px;
+}
+
+.listing-card dt {
+ color: #66746c;
+ font-size: 0.75rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.listing-card dd {
+ margin: 0;
+}
+
+@media (max-width: 640px) {
+ .site-header {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px 20px;
+ }
+
+ .app-shell {
+ padding: 32px 20px 56px;
+ }
+}
diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs
new file mode 100644
index 0000000..9281972
--- /dev/null
+++ b/tests/catalog.test.mjs
@@ -0,0 +1,48 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ categories,
+ getCategory,
+ getCategoryCounts,
+ getListingsByCategory,
+ listings,
+ validateCatalog
+} from "../src/catalog.js";
+
+test("catalog defines the expected starter taxonomy", () => {
+ assert.equal(categories.length, 7);
+ assert.deepEqual(
+ categories.map((category) => category.slug),
+ [
+ "ai-ml-apis",
+ "data-feeds",
+ "compute",
+ "storage",
+ "content",
+ "developer-tools",
+ "identity-auth"
+ ]
+ );
+});
+
+test("every listing maps to a valid category", () => {
+ const result = validateCatalog();
+
+ assert.equal(result.valid, true);
+ assert.deepEqual(result.duplicateSlugs, []);
+ assert.deepEqual(result.invalidListings, []);
+});
+
+test("category pages can filter listings by slug", () => {
+ assert.equal(getCategory("compute").name, "Compute");
+ assert.deepEqual(
+ getListingsByCategory("compute").map((listing) => listing.id),
+ ["micro-render"]
+ );
+});
+
+test("category counts reflect the listing data", () => {
+ const totalFromCounts = getCategoryCounts().reduce((sum, category) => sum + category.count, 0);
+
+ assert.equal(totalFromCounts, listings.length);
+});