diff --git a/catalog/discover-the-catalog/README.md b/catalog/discover-the-catalog/README.md new file mode 100644 index 00000000..1974c617 --- /dev/null +++ b/catalog/discover-the-catalog/README.md @@ -0,0 +1,150 @@ +# What's in the Catalog? + +**The same catalog of pay-per-use AI agents, read three ways β€” by eye, by an agent, and by a crawler.** + +Most people meet the Nevermined Catalog through a demo that *spends* β€” an agent that finds five data sources and pays them itself. But before anything gets paid for, it has to be **found**. This tutorial is about that first half: how you (or your agent) discover what's out there β€” what agents exist, what they do, and which payment rails they speak β€” over a catalog that's **public, unauthenticated, and free to read**. + +πŸ–₯️ **Try it visually:** the interactive [**playground**](./playground) β€” type one question and watch it answered three ways, live against the real catalog. (`cd playground && node server.mjs`) +🎬 **Watch it:** [`discover-the-catalog.mp4`](./discover-the-catalog.mp4) β€” a ~75-second walkthrough (English + Spanish subtitles). +⌨️ **Or just run it:** [`./run-demo.sh`](./run-demo.sh) β€” a free, no-key tour of every discovery surface from your terminal. + +> Live today: **150+ agents across 13 categories**, over **two machine-payment rails** (x402 and MPP). No signup to look. + +--- + +## Why this is interesting + +To use a paid online service the old way, a person signs up, enters a card, and manages an API key β€” *per provider*. The Nevermined Catalog is built for a world where an **agent** does the finding and paying. For that to work, the catalog has to be **discoverable by machines**, not just browsable by people. + +So it's published at three altitudes over the *same* data: + +- **By eye** β€” a website a person browses. +- **By an agent** β€” an API (and an MCP tool) an agent queries at runtime: "what can enrich a company, and get paid per call?" +- **By a crawler** β€” one standards-compliant feed (Google's *Agentic Resource Discovery*, ARD) that any registry can ingest. + +The payoff isn't just a list of services. Ask the catalog a question and it can tell you the **structure** behind the answer β€” which payment rails, which categories, which capabilities match β€” so you discover *the shape of what's available*, not just names. + +## The same catalog, three ways + +| Altitude | Who it's for | Surface | +|---|---|---| +| **By eye** | a person | The catalog website β€” [`nevermined.app/catalog/`](https://nevermined.app/catalog/) | +| **By an agent** | your code / your agent | REST `GET /api/v1/catalog/services` Β· the **Catalog MCP** (`search_services`) Β· the **ARD registry** (`/ard/search`, `/ard/explore`) | +| **By a crawler** | any registry / the open web | The ARD feed β€” `GET /.well-known/ard.json` | + +All of it is read-only and needs **no `Authorization` header**. The base URL is `https://api.live.nevermined.app` for the production catalog, or `https://api.sandbox.nevermined.app` to experiment. + +--- + +## Try it β€” the visual playground + +The [`playground/`](./playground) is a tiny web app: one search box drives three synchronized panes β€” the human cards, the exact API call an agent makes (MCP / REST / ARD, with its JSON reply), and the machine-feed entry β€” plus a live `/explore` histogram of the protocols and tags behind your results. + +```bash +cd playground +node server.mjs # β†’ http://localhost:8080 (sandbox) +NVM_TIER=live node server.mjs # the production catalog +``` + +It needs Node 18+ and **no dependencies**. Why a small server instead of a plain page? The catalog API only allows browser calls from `*.nevermined.app`, so the server sits alongside the page and forwards the discovery calls β€” the same trick you'd use in your own app. See [`playground/README.md`](./playground/README.md). + +## Try it β€” from the terminal + +```bash +./run-demo.sh # sandbox, no key, no payment +NVM_TIER=live Q="web scraping" ./run-demo.sh +``` + +It walks every surface in order β€” categories, the two rails, REST search, ARD search + explore, the MCP tool, and the `.well-known` feed β€” printing the request it makes each time. + +--- + +## Do it yourself + +Everything below is a real, copy-pasteable call. Nothing here spends money. + +### β‘  By eye β€” the REST catalog + +```bash +API=https://api.live.nevermined.app + +# What categories exist, and how many agents in each? +curl -s "$API/api/v1/catalog/categories" | jq . + +# Search by keyword; filter by rail. (search is a plain keyword match β€” one word works best.) +curl -s "$API/api/v1/catalog/services?search=enrichment&protocol=x402" | jq '.total, .services[0]' + +# The full record for one service, by slug: +curl -s "$API/api/v1/catalog/services/stableenrich" | jq . +``` + +Useful fields: `slug` (stable id), `protocol` (**only `x402` and `mpp` are payable through the Router**), `targetUrl`, `endpoints[]`, `priceLabel`, `category`, `tags[]`. + +### β‘‘ By an agent β€” the Catalog MCP + +Point any MCP client at the hosted server β€” no key needed for discovery: + +``` +https://mcp.live.nevermined.app/mcp (or mcp.sandbox.nevermined.app) +``` + +It exposes `list_categories`, `search_services`, and `get_service` (plus paid `pay_service` / ledger tools). In Claude Code: + +```bash +claude mcp add --transport http nevermined https://mcp.sandbox.nevermined.app/mcp +``` + +Then just ask: *"Using the Nevermined catalog, what agents can enrich a company? Which speak x402?"* β€” the model calls `search_services` for you. + +### β‘‘ By an agent β€” the ARD registry (relevance search + facets) + +Unlike the keyword REST search, ARD `/search` ranks the **whole question**. Mind the body shape β€” the query is **nested**, and a bare `{"text":…}` is rejected: + +```bash +# Ranked search β€” hand it a natural-language need. +curl -s -X POST "$API/api/v1/ard/search" -H 'content-type: application/json' -d '{ + "query": { "text": "which agents can enrich a company?", + "filter": { "pay:protocol": ["x402"] } }, + "pageSize": 5 }' | jq '.results[] | {displayName, score}' + +# Explore β€” the STRUCTURE of a query: protocols, media types, tags. (resultType.facets is required.) +curl -s -X POST "$API/api/v1/ard/explore" -H 'content-type: application/json' -d '{ + "query": { "text": "enrichment" }, + "resultType": { "facets": [ {"field":"pay:protocol"}, {"field":"tags","limit":6} ] } }' | jq .facets + +# Browse β€” deterministic, cacheable, paged. +curl -s "$API/api/v1/ard/agents?pageSize=5" | jq '.items[].displayName' +``` + +`filter` terms: `type`, `tags`, `capabilities`, `publisher`, `pay:protocol`, `pay:currency`, `pay:network`, `pay:price`. + +### β‘’ By a crawler β€” the machine feed + +```bash +curl -s "$API/.well-known/ard.json" | jq '{specVersion, host: .host.identifier, entries: (.entries|length)}' +``` + +One JSON document listing every agent with its `representativeQueries`, `tags`, `trustManifest`, and an `nvm:catalog` block (protocol, price, endpoints). This is what a crawler or another registry ingests β€” the catalog on the open web. + +--- + +## Two rules that cost real money once you *do* pay + +Discovery is free, but the moment you route a payment, two things bite: + +1. **Only `x402` and `mpp` are routable.** A `rest`/`a2a`/`other` listing can't be paid through the Router β€” filter with `?protocol=x402` / `mpp`. +2. **`targetUrl` is a full URL, not a base.** Resolve endpoint paths against its *origin*, not by concatenation, or you'll 404 a call you paid for. + +## Found something? Now pay for it. + +Discovery hands you a `slug`, a `protocol`, and an endpoint. From there, one Router call pays and invokes it β€” see the sibling demos: + +- [**Song From the Headlines**](../song-from-the-headlines) β€” an agent discovers and pays four tools to make a song. +- [**Diligence-in-a-Box**](../diligence-in-a-box) β€” five diligence sources, two rails, one investment memo. + +## Learn more + +- **The Catalog** β€” [nevermined.app/catalog/](https://nevermined.app/catalog/) Β· [docs](https://nevermined.ai/docs/products/catalog/overview) +- **ARD** β€” the Agentic Resource Discovery feed that makes the catalog crawlable by any registry. + +*Part of the Nevermined tutorials. Every call in this guide is public, read-only, and unauthenticated. Service names and figures come from the live catalog and change as it grows.* diff --git a/catalog/discover-the-catalog/demo-prompt.txt b/catalog/discover-the-catalog/demo-prompt.txt new file mode 100644 index 00000000..d613fb78 --- /dev/null +++ b/catalog/discover-the-catalog/demo-prompt.txt @@ -0,0 +1,13 @@ +The one prompt β€” discovery, no payment. + +Handed to an agent that has the Nevermined Catalog MCP server attached +(https://mcp.sandbox.nevermined.app/mcp β€” the list_categories / search_services / +get_service tools; no API key needed for discovery): + + "Using the Nevermined catalog, find agents that can enrich a company. + Which ones can I pay per call β€” and over which payment rails? + Don't pay for anything; just tell me what's available and how it's split." + +The agent calls search_services, reads the protocol on each listing, and reports +back the matches grouped by rail (x402 vs MPP) β€” the catalog answering "what's out +there?" before a single payment is made. diff --git a/catalog/discover-the-catalog/discover-the-catalog.en.srt b/catalog/discover-the-catalog/discover-the-catalog.en.srt new file mode 100644 index 00000000..33b7b9ac --- /dev/null +++ b/catalog/discover-the-catalog/discover-the-catalog.en.srt @@ -0,0 +1,34 @@ +1 +00:00:01,200 --> 00:00:06,499 +This is the Nevermined Catalog. A directory +of AI agents you can pay for, per call. + +2 +00:00:11,500 --> 00:00:18,252 +By eye, you just browse it. Filter by category, +or by the payment rail you can use. + +3 +00:00:20,500 --> 00:00:26,189 +Open one, and you see exactly how it's paid. +A rail, a network, a price per call. + +4 +00:00:29,000 --> 00:00:34,505 +By an agent, the same catalog answers in code. +Ask what can enrich a company. + +5 +00:00:34,800 --> 00:00:41,447 +It lists them, grouped by rail. +Discovery only β€” nothing paid yet. + +6 +00:00:42,300 --> 00:00:47,024 +By a crawler, the whole catalog is one open +feed any registry can read. + +7 +00:00:51,000 --> 00:00:56,068 +Find first. Pay per call second. +Explore the Nevermined Catalog. diff --git a/catalog/discover-the-catalog/discover-the-catalog.es.srt b/catalog/discover-the-catalog/discover-the-catalog.es.srt new file mode 100644 index 00000000..2b7f944d --- /dev/null +++ b/catalog/discover-the-catalog/discover-the-catalog.es.srt @@ -0,0 +1,34 @@ +1 +00:00:01,200 --> 00:00:06,499 +Este es el catΓ‘logo de Nevermined. Un directorio +de agentes de IA que pagas por llamada. + +2 +00:00:11,500 --> 00:00:18,252 +A simple vista, lo exploras. Filtra por categorΓ­a, +o por el raΓ­l de pago que puedes usar. + +3 +00:00:20,500 --> 00:00:26,189 +Abre uno y ves exactamente cΓ³mo se paga. +Un raΓ­l, una red, un precio por llamada. + +4 +00:00:29,000 --> 00:00:34,505 +Por un agente, el catΓ‘logo responde en cΓ³digo. +Pregunta quΓ© puede enriquecer una empresa. + +5 +00:00:34,800 --> 00:00:41,447 +Y los lista, agrupados por raΓ­l. +Solo descubrimiento, nada pagado aΓΊn. + +6 +00:00:42,300 --> 00:00:47,024 +Por un rastreador, todo el catΓ‘logo es un feed +abierto que cualquier registro puede leer. + +7 +00:00:51,000 --> 00:00:56,068 +Primero encuentra. Luego paga, por llamada. +Explora el catΓ‘logo de Nevermined. diff --git a/catalog/discover-the-catalog/discover-the-catalog.mp4 b/catalog/discover-the-catalog/discover-the-catalog.mp4 new file mode 100644 index 00000000..b8f6c7f9 Binary files /dev/null and b/catalog/discover-the-catalog/discover-the-catalog.mp4 differ diff --git a/catalog/discover-the-catalog/playground/README.md b/catalog/discover-the-catalog/playground/README.md new file mode 100644 index 00000000..e50331b5 --- /dev/null +++ b/catalog/discover-the-catalog/playground/README.md @@ -0,0 +1,40 @@ +# Catalog Discovery playground + +One search box, three synchronized views of the **live** Nevermined Agent Services Catalog: + +- **By eye** β€” the cards a person browses (REST catalog). +- **By an agent** β€” the exact call an agent makes, tabbed across **MCP**, **REST**, and **ARD `/search`**, with the JSON reply. +- **By a crawler** β€” the matching `/.well-known/ard.json` entry. + +…plus a live **ARD `/explore`** histogram of the payment rails, media types, and tags behind your results. + +## Run it + +```bash +node server.mjs # β†’ http://localhost:8080 (sandbox) +NVM_TIER=live node server.mjs # the production catalog +PORT=3000 node server.mjs +``` + +Node 18+ (uses the built-in `fetch`). **No dependencies, no build step.** `npm start` works too. + +## Deep links + +State is shareable and drives the demo capture: + +``` +/?q=which agents can enrich a company?&tab=ard&rails=x402 +``` + +`q` = the question Β· `tab` = `mcp` | `ard` | `rest` Β· `rails` = `x402`, `mpp` (comma-separated). + +## Why there's a server (and not just an HTML file) + +The catalog API is public, but its CORS policy only allows browser calls from `*.nevermined.app`. A page served from anywhere else (localhost, a static host) would be blocked. `server.mjs` sits **same-origin** with the page and forwards the discovery calls server-side β€” the same small proxy you'd add to your own app. It relays only to the two fixed Nevermined upstreams (`api.` and `mcp.`), so it can't be used as an open relay. + +## Deploying + +- **Anywhere (Vercel, Render, a container, a VM):** ship this folder and run `HOST=0.0.0.0 node server.mjs` β€” the proxy handles CORS. (It binds `127.0.0.1` by default, so set `HOST=0.0.0.0` to make it reachable from outside the container/VM.) +- **Under a `*.nevermined.app` origin:** the browser may call the API directly; the proxy becomes optional. + +`NVM_TIER` (`sandbox` | `live`, default `sandbox`), `PORT` (default `8080`) and `HOST` (default `127.0.0.1`) are the only configuration. diff --git a/catalog/discover-the-catalog/playground/package.json b/catalog/discover-the-catalog/playground/package.json new file mode 100644 index 00000000..4e5e17b5 --- /dev/null +++ b/catalog/discover-the-catalog/playground/package.json @@ -0,0 +1,14 @@ +{ + "name": "catalog-discovery-playground", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Live discovery playground for the Nevermined Agent Services Catalog β€” one question, three altitudes.", + "scripts": { + "start": "node server.mjs", + "dev": "node server.mjs" + }, + "engines": { + "node": ">=18" + } +} diff --git a/catalog/discover-the-catalog/playground/public/index.html b/catalog/discover-the-catalog/playground/public/index.html new file mode 100644 index 00000000..fb64d3ef --- /dev/null +++ b/catalog/discover-the-catalog/playground/public/index.html @@ -0,0 +1,423 @@ + + + + + +What's in the Catalog? + + + + + + +
+ +
+
+

What's in the Catalog?

+

One question, answered by eye, by an agent, and by a crawler β€” over the same live, machine-payable catalog. No signup.

+
+
+
β€”agents
+
β€”categories
+
β€”β€”x402 / MPP rails
+
+
+ +
+
+
+ >_ + +
+ +
+
+ +
+ live + rails + + + try + +
+ +
+
+
By eyewhat a person browses
+
Ask a question to see matching agents.
+
+ +
+
By an agentthe live API call it makes
+
+
+ + + +
+
The request appears here.
+
// response
+
+
+ +
+
By a crawler.well-known/ard.json
+
The open-web feed entry for the top match.
+
+
+ +
+

What's in this answer

+

The same query, run through ARD /explore β€” a live histogram of the protocols, media types and tags behind your results. This is how you discover the catalog's structure, not just its listings.

+
+
Run a query to see its facets.
+
+
+ + + +
+ + + + diff --git a/catalog/discover-the-catalog/playground/server.mjs b/catalog/discover-the-catalog/playground/server.mjs new file mode 100644 index 00000000..5ffb448d --- /dev/null +++ b/catalog/discover-the-catalog/playground/server.mjs @@ -0,0 +1,115 @@ +// Catalog Discovery playground β€” zero-dependency static server + discovery proxy. +// +// Why a proxy at all: the Nevermined discovery API is public and unauthenticated, but its CORS +// allowlist only reflects `*.nevermined.app` origins β€” so a browser page served from anywhere else +// (localhost, Vercel, the tutorials example) is blocked. This server sits same-origin with the page +// and forwards the discovery calls server-side, where CORS does not apply. It also lets the browser +// reach the MCP endpoint (which speaks JSON-RPC/SSE, not browser-friendly cross-origin). +// +// ponytail: zero deps β€” Node 18+ `http` + global `fetch`, no framework, no node_modules. `node server.mjs`. +// +// Security: it proxies ONLY to the two fixed Nevermined upstreams below, chosen by URL PREFIX. There is +// no user-controlled destination host, so it can't be turned into an open relay (no SSRF surface). + +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { dirname, join, normalize, extname } from 'node:path' + +const PORT = Number(process.env.PORT) || 8080 +const HOST = process.env.HOST || '127.0.0.1' // localhost by default (matches the banner + README); set HOST=0.0.0.0 to expose in a container +const TIER = process.env.NVM_TIER === 'live' ? 'live' : 'sandbox' // discovery is read-only; default safe +const API_BASE = `https://api.${TIER}.nevermined.app` +const MCP_BASE = `https://mcp.${TIER}.nevermined.app` + +const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'public') +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +} + +/** Read the raw request body (JSON discovery calls are tiny; cap to be safe). */ +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = [] + let size = 0 + req.on('data', (c) => { + size += c.length + if (size > 256 * 1024) reject(new Error('body too large')) + else chunks.push(c) + }) + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) +} + +/** Forward one request to a fixed upstream and relay status + JSON/text back. */ +async function proxy(req, res, upstreamUrl) { + const method = req.method || 'GET' + const headers = { accept: 'application/json, text/event-stream' } + let body + if (method !== 'GET' && method !== 'HEAD') { + body = await readBody(req) + headers['content-type'] = req.headers['content-type'] || 'application/json' + } + const upstream = await fetch(upstreamUrl, { method, headers, body }) + const text = await upstream.text() + res.writeHead(upstream.status, { + 'content-type': upstream.headers.get('content-type') || 'application/json', + 'cache-control': 'no-store', + }) + res.end(text) +} + +async function serveStatic(res, urlPath) { + const rel = urlPath === '/' ? '/index.html' : urlPath + // Contain within PUBLIC_DIR β€” reject any path that escapes it. + const full = normalize(join(PUBLIC_DIR, rel)) + if (!full.startsWith(PUBLIC_DIR)) return notFound(res) + try { + const data = await readFile(full) + res.writeHead(200, { 'content-type': MIME[extname(full)] || 'application/octet-stream' }) + res.end(data) + } catch { + notFound(res) + } +} + +function notFound(res) { + res.writeHead(404, { 'content-type': 'text/plain' }) + res.end('Not found') +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://localhost:${PORT}`) + const p = url.pathname + + // Discovery surfaces β†’ API host (REST catalog + ARD registry + well-known feed). + if (p.startsWith('/api/') || p.startsWith('/.well-known/')) { + return await proxy(req, res, API_BASE + p + url.search) + } + // Catalog MCP (JSON-RPC) β†’ MCP host. + if (p === '/mcp') { + return await proxy(req, res, MCP_BASE + '/mcp') + } + // Config the page reads on load (which tier it's talking to). + if (p === '/config.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + return res.end(JSON.stringify({ tier: TIER, apiBase: API_BASE, mcpBase: MCP_BASE })) + } + return await serveStatic(res, p) + } catch (err) { + res.writeHead(502, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'proxy_error', message: String(err?.message || err) })) + } +}) + +server.listen(PORT, HOST, () => { + console.log(`Catalog Discovery playground β†’ http://localhost:${PORT} (tier: ${TIER})`) +}) diff --git a/catalog/discover-the-catalog/run-demo.sh b/catalog/discover-the-catalog/run-demo.sh new file mode 100755 index 00000000..721ebc87 --- /dev/null +++ b/catalog/discover-the-catalog/run-demo.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Discover the Catalog β€” a Nevermined Catalog demo. +# The same catalog, read three ways: by eye, by an agent, and by a crawler. +# +# This is a DISCOVERY tour. It reads the public catalog only β€” no account, no API key, +# no payment. Every call below is unauthenticated and free. +# +# Prereqs: curl + jq. (python3 optional, only for prettier facet bars.) +# Run: ./run-demo.sh # sandbox by default +# NVM_TIER=live ./run-demo.sh # the production catalog +# Q="web scraping" ./run-demo.sh +set -euo pipefail + +TIER="${NVM_TIER:-sandbox}" # discovery is read-only; sandbox is the safe default +API="https://api.${TIER}.nevermined.app" +MCP="https://mcp.${TIER}.nevermined.app/mcp" +Q="${Q:-company enrichment}" # the thing we're looking for + +b(){ printf '\n\033[1;36m%s\033[0m\n' "$*"; } # section header +dim(){ printf '\033[2m%s\033[0m\n' "$*"; } + +b "β–Έ The catalog at a glance (tier: $TIER)" +dim "GET $API/api/v1/catalog/categories" +curl -s "$API/api/v1/catalog/categories" \ + | jq -r 'sort_by(-.count)[] | " \(.count|tostring|(" "*(4-length))+.) \(.category)"' +TOTAL=$(curl -s "$API/api/v1/catalog/categories" | jq '[.[].count] | add') +X402=$(curl -s "$API/api/v1/catalog/services?protocol=x402&offset=1" | jq .total) +MPP=$(curl -s "$API/api/v1/catalog/services?protocol=mpp&offset=1" | jq .total) +echo " ── $TOTAL agents Β· x402: $X402 Β· MPP: $MPP (the two payment rails you can use) ──" + +b "β‘  BY EYE β€” the REST catalog a person (or a website) browses" +dim "GET $API/api/v1/catalog/services?search=$(printf %s "$Q" | jq -sRr @uri)&offset=5" +curl -s "$API/api/v1/catalog/services?search=$(printf %s "$Q" | jq -sRr @uri)&offset=5" \ + | jq -r '.services[] | " β€’ \(.title) [\(.protocol)] β€” \(.shortDescription[0:70])"' + +b "β‘‘ BY AN AGENT β€” the ARD registry: relevance-ranked search over the whole question" +dim "POST $API/api/v1/ard/search { query: { text: \"$Q\" } }" +curl -s -X POST "$API/api/v1/ard/search" -H 'content-type: application/json' \ + -d "$(jq -n --arg t "$Q" '{query:{text:$t},pageSize:5}')" \ + | jq -r '.results[] | " \(.score|tostring|(" "*(3-length))+.) \(.displayName) [\((."nvm:catalog".protocol) // "?")]"' + +b " …and ARD /explore β€” the STRUCTURE behind those results (this is the part people miss)" +dim "POST $API/api/v1/ard/explore { query:{text}, resultType:{ facets:[pay:protocol, tags] } }" +curl -s -X POST "$API/api/v1/ard/explore" -H 'content-type: application/json' \ + -d "$(jq -n --arg t "$Q" '{query:{text:$t},resultType:{facets:[{field:"pay:protocol"},{field:"tags",limit:6}]}}')" \ + | jq -r ' + " payment rails:", (.facets."pay:protocol".buckets[] | " \(.value) Γ—\(.count)"), + " top tags:", (.facets.tags.buckets[] | " \(.value) Γ—\(.count)")' + +b " MCP β€” the very same discovery, exposed as a tool an agent can call" +dim "POST $MCP tools/call search_services { query: \"$Q\" }" +curl -s -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d "$(jq -n --arg t "$Q" '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"search_services",arguments:{query:$t,offset:3}}}')" \ + | jq -r '.result.content[0].text | fromjson | " \(.total) matches, e.g. \(.services[0].title) [\(.services[0].protocol)]"' + +b "β‘’ BY A CRAWLER β€” the whole catalog as one agent-ready (Google ARD) feed" +dim "GET $API/.well-known/ard.json" +curl -s "$API/.well-known/ard.json" \ + | jq -r '" specVersion \(.specVersion) Β· host \(.host.identifier) Β· \(.entries|length) entries", + " sample entry: \(.entries[0].displayName) β€” \(.entries[0].representativeQueries[0])"' + +b "Done." +echo "Found something? Pay for and call it through the Router β€” see ../diligence-in-a-box or ../song-from-the-headlines." +echo "Or explore it visually: run ./playground (see playground/README.md)." diff --git a/showcase/Dockerfile b/showcase/Dockerfile index 25a06da1..ad550db7 100644 --- a/showcase/Dockerfile +++ b/showcase/Dockerfile @@ -20,8 +20,9 @@ FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY showcase/ ./ -# The recap videos are gitignored under showcase/public β€” pull them from the -# committed catalog/ demos so the recap panels play in the image. +# The demo videos are gitignored under showcase/public β€” pull them from the +# committed catalog/ demos so the recap/discover panels play in the image. +COPY catalog/discover-the-catalog/discover-the-catalog.mp4 ./public/media/discover-the-catalog/ COPY catalog/song-from-the-headlines/song-from-the-headlines.mp4 ./public/media/song-from-the-headlines/ COPY catalog/diligence-in-a-box/diligence-in-a-box.mp4 ./public/media/diligence-in-a-box/ RUN npm run build diff --git a/showcase/README.md b/showcase/README.md index 1f852286..c27a9478 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -71,9 +71,13 @@ Each tutorial declares a **tier**: 200 + settlement` β€” with a real per-session credit balance that decrements per call and responses that react to what you type. It talks to a **local sandbox agent** (`lib/demo-agent.mjs`), so it spends no real money and needs no credentials or backend. -- **`recap`** β€” the two `catalog/` demos spend real crypto autonomously across chains, so they are +- **`recap`** β€” the two paid `catalog/` demos spend real crypto autonomously across chains, so they are **watch-only**: embedded video, playable outputs, the on-chain receipt, and a "run it locally" note. +- **`discover`** β€” the catalog *discovery* demo (`discover-the-catalog`) is read-only and free, so its + `See it run` panel is **functional in the browser**: it queries the real, public catalog live (REST, + the Catalog MCP, and the ARD registry) through the `/api/catalog` same-origin proxy β€” no credentials, + no payment, ever. See `components/DiscoverPanel.tsx` + `app/api/catalog/route.ts`. ## The sandbox agent, and going fully real @@ -108,13 +112,16 @@ showcase/ β”‚ β”œβ”€β”€ page.tsx # overview β€” intro + grouped index of all tutorials β”‚ β”œβ”€β”€ t/[slug]/page.tsx # tutorial page β€” the 4 normalized sections β”‚ β”œβ”€β”€ api/agent/route.ts # the "see it run" endpoint (cookie state β†’ sandbox agent) +β”‚ β”œβ”€β”€ api/catalog/route.ts # same-origin proxy for live catalog discovery (discover tier) β”‚ └── globals.css # the light, docs-style design system (tokens) β”œβ”€β”€ components/ β”‚ β”œβ”€β”€ AppShell.tsx # persistent left sidebar + mobile drawer + active state β”‚ β”œβ”€β”€ LiveRunPanel.tsx # the interactive "see it run" panel (real fetches β†’ /api/agent) +β”‚ β”œβ”€β”€ DiscoverPanel.tsx # functional catalog discovery panel (real fetches β†’ /api/catalog) β”‚ └── RecapPanel.tsx # video + outputs + receipt (recap tier) β”œβ”€β”€ content/tutorials.ts # ← all tutorial content + sidebar grouping live here └── lib/ β”œβ”€β”€ types.ts # the normalized content model - └── demo-agent.mjs # sandbox agent logic (x402 handshake) + `node` self-test + β”œβ”€β”€ demo-agent.mjs # sandbox agent logic (x402 handshake) + `node` self-test + └── catalog-discovery.mjs # discovery request builders + parsers + `node` self-test ``` diff --git a/showcase/app/api/catalog/route.ts b/showcase/app/api/catalog/route.ts new file mode 100644 index 00000000..d0e39d39 --- /dev/null +++ b/showcase/app/api/catalog/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + keyword, + normalizeRails, + restRequest, + ardRequest, + mcpRequest, + exploreRequest, + parseUpstream, + parseMcpResult, + matchFeedEntry, +} from "@/lib/catalog-discovery.mjs"; + +// Same-origin proxy for the "discover" tutorial's See-it-run panel. +// +// Why it exists: every catalog/ARD/MCP discovery endpoint is PUBLIC, unauthenticated and free, +// but the API's CORS allowlist only reflects `*.nevermined.app` origins β€” so a browser calling it +// from the showcase's origin is blocked. This route sits same-origin with the page and forwards the +// discovery calls server-side, where CORS does not apply (exactly what the standalone playground's +// server.mjs does). No credentials are involved: discovery never needs an API key or a wallet. +// +// SSRF-safe: the client picks an `op` from a fixed set; the upstream URL + path are built entirely +// server-side by our own request builders. There is no client-controlled destination host or path, +// so this can't be turned into an open relay. + +const TIER = process.env.NVM_CATALOG_TIER === "sandbox" ? "sandbox" : "live"; +const API_BASE = `https://api.${TIER}.nevermined.app`; +const MCP_BASE = `https://mcp.${TIER}.nevermined.app`; +const TIMEOUT_MS = 15_000; + +function upstreamUrl(path: string): string { + if (path === "/mcp") return `${MCP_BASE}/mcp`; + return `${API_BASE}${path}`; // /api/... and /.well-known/... only (paths are ours, never the client's) +} + +async function getJson(path: string): Promise { + const res = await fetch(upstreamUrl(path), { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + cache: "no-store", + }); + return parseUpstream(await res.text()); +} + +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(upstreamUrl(path), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + cache: "no-store", + }); + return parseUpstream(await res.text()); +} + +// The crawler feed is ~160 entries and changes slowly β€” cache it in-process (soft 10-min TTL) +// so a search doesn't refetch the whole document on every keystroke. +// ponytail: module-level cache, per-instance; fine for a read-only marketing demo. +type FeedCache = { at: number; entries: unknown[] }; +let feedCache: FeedCache | null = null; +async function feedEntries(): Promise { + if (feedCache && Date.now() - feedCache.at < 10 * 60_000) return feedCache.entries; + try { + const feed = (await getJson("/.well-known/ard.json")) as { entries?: unknown[] }; + feedCache = { at: Date.now(), entries: Array.isArray(feed.entries) ? feed.entries : [] }; + } catch { + feedCache = { at: Date.now(), entries: [] }; + } + return feedCache.entries; +} + +type Payload = { op?: string; text?: string; rails?: string[] }; + +export async function POST(req: NextRequest) { + let payload: Payload; + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: "bad request" }, { status: 400 }); + } + + const rails = normalizeRails(payload.rails); + const text = (payload.text || "").trim(); + + try { + if (payload.op === "boot") { + const [cats, x402, mpp] = await Promise.all([ + getJson("/api/v1/catalog/categories"), + getJson("/api/v1/catalog/services?protocol=x402&offset=1"), + getJson("/api/v1/catalog/services?protocol=mpp&offset=1"), + ]); + const catList = Array.isArray(cats) ? (cats as { count?: number }[]) : []; + return NextResponse.json({ + tier: TIER, + stats: { + categories: catList.length, + total: catList.reduce((s, c) => s + (c.count || 0), 0), + x402: (x402 as { total?: number })?.total ?? null, + mpp: (mpp as { total?: number })?.total ?? null, + }, + }); + } + + if (payload.op === "search") { + if (!text) return NextResponse.json({ error: "empty query" }, { status: 400 }); + const kw = keyword(text); + const rest = restRequest(kw, rails); + const ard = ardRequest(text, rails); + const mcp = mcpRequest(kw, rails); + + const [restRes, ardRes, mcpRaw, entries] = await Promise.all([ + getJson(rest.path), + postJson(ard.path, ard.body), + postJson(mcp.path, mcp.body), + feedEntries(), + ]); + const mcpRes = parseMcpResult(mcpRaw); + + return NextResponse.json({ + keyword: kw, + rest: { req: rest.display, res: restRes }, + ard: { req: ard.display, res: ardRes }, + mcp: { req: mcp.display, res: mcpRes }, + feed: { entry: matchFeedEntry(entries, restRes, ardRes) }, + }); + } + + if (payload.op === "explore") { + if (!text) return NextResponse.json({ error: "empty query" }, { status: 400 }); + const ex = exploreRequest(text); + const data = (await postJson(ex.path, ex.body)) as { facets?: unknown }; + return NextResponse.json({ facets: data?.facets ?? {} }); + } + + return NextResponse.json({ error: "unknown op" }, { status: 400 }); + } catch (err) { + return NextResponse.json( + { error: "proxy_error", message: err instanceof Error ? err.message : String(err) }, + { status: 502 }, + ); + } +} diff --git a/showcase/app/globals.css b/showcase/app/globals.css index ee9dc71a..c329739f 100644 --- a/showcase/app/globals.css +++ b/showcase/app/globals.css @@ -401,6 +401,122 @@ table.dt tr.total td:last-child { color: var(--paid); } border-radius: 10px; padding: 9px 14px; display: inline-flex; align-items: center; gap: 8px; } .explore a:hover { background: var(--accent-wash); } +/* ---------------- DISCOVER PANEL β€” functional, read-only catalog discovery ---------------- */ +/* Two payment rails as a data encoding: x402 = teal accent, MPP = amber. */ +.dsc { --dsc-x402: var(--accent); --dsc-mpp: var(--pay); } +.dsc code { font-family: var(--mono); font-size: 0.85em; color: var(--accent-ink); background: var(--accent-wash); + padding: 1px 5px; border-radius: 4px; } + +.dsc-readout { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px 26px; margin-bottom: 16px; } +.dsc-stat { display: flex; flex-direction: column; gap: 2px; } +.dsc-stat b { font-family: var(--mono); font-weight: 600; font-size: clamp(22px, 3vw, 30px); line-height: 1; color: var(--ink); + font-variant-numeric: tabular-nums; } +.dsc-stat span { font-size: 12px; color: var(--muted); } +.dsc-stat.rails b { display: flex; gap: 7px; align-items: baseline; } +.dsc-stat.rails .rx { color: var(--dsc-x402); } .dsc-stat.rails .rm { color: var(--dsc-mpp); } +.dsc-stat.rails .sep { color: var(--faint); font-size: 20px; } +.dsc-tier { margin-left: auto; align-self: flex-end; font-family: var(--mono); font-size: 12px; color: var(--muted); + display: inline-flex; align-items: center; gap: 7px; } +.dsc-tier .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--paid); flex: none; + box-shadow: 0 0 0 0 rgba(10, 161, 95, 0.5); animation: pulse 2.4s infinite; } + +.dsc-command { display: flex; gap: 10px; align-items: stretch; margin-bottom: 12px; } +.dsc-qbox { flex: 1; display: flex; align-items: center; gap: 11px; background: var(--bg); border: 1.5px solid var(--border-strong); + border-radius: 12px; padding: 0 15px; min-height: 54px; transition: border-color 0.14s, box-shadow 0.14s; } +.dsc-qbox:focus-within { border-color: var(--accent); box-shadow: var(--ring); } +.dsc-qbox .glyph { font-family: var(--mono); color: var(--accent); font-size: 16px; font-weight: 600; } +.dsc-qbox input { flex: 1; background: none; border: 0; outline: 0; color: var(--ink); font-family: var(--sans); + font-size: 16px; padding: 0; } +.dsc-qbox input::placeholder { color: var(--faint); } +.dsc-run { background: var(--ink); color: #fff; border: 0; border-radius: 12px; padding: 0 22px; font-weight: 700; + font-size: 14px; cursor: pointer; min-height: 54px; white-space: nowrap; } +.dsc-run:hover { background: #0d3330; } .dsc-run:disabled { opacity: 0.6; cursor: default; } + +.dsc-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 20px; } +.dsc-chips .lbl { font-size: 12px; color: var(--faint); } .dsc-chips .lbl.gap { margin-left: 8px; } +.dsc-chip { font-family: var(--mono); font-size: 12px; color: var(--ink-soft); background: var(--bg); border: 1px solid var(--border-strong); + border-radius: 999px; padding: 5px 11px; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; + transition: border-color 0.12s, background 0.12s, color 0.12s; } +.dsc-chip:hover { border-color: var(--ink); } .dsc-chip:disabled { opacity: 0.55; cursor: default; } +.dsc-chip .cnt { color: var(--faint); font-size: 11px; } +.dsc-chip .swatch { width: 8px; height: 8px; border-radius: 2px; } +.dsc-chip.x402 .swatch { background: var(--dsc-x402); } .dsc-chip.mpp .swatch { background: var(--dsc-mpp); } +.dsc-chip.x402[data-on="1"] { color: var(--dsc-x402); border-color: var(--dsc-x402); background: var(--accent-wash); } +.dsc-chip.mpp[data-on="1"] { color: var(--pay-ink); border-color: var(--dsc-mpp); background: var(--pay-wash); } +.dsc-chip.preset { border-style: dashed; } +.dsc-chip.preset:hover { border-color: var(--accent); color: var(--accent-ink); } + +.dsc-err { color: var(--danger-ink); background: var(--danger-wash); border: 1.5px solid var(--danger); border-radius: 11px; + padding: 11px 14px; font-size: 13px; margin-bottom: 16px; } + +.dsc-altitudes { display: grid; grid-template-columns: 1fr 1.1fr 1fr; gap: 14px; } +.dsc-pane { background: var(--bg); border: 1.5px solid var(--border-strong); border-radius: 14px; overflow: hidden; + display: flex; flex-direction: column; min-height: 400px; max-height: 620px; box-shadow: var(--shadow); position: relative; } +.dsc-pane::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 3px; + background: linear-gradient(var(--tick, var(--border-strong)), transparent); } +.dsc-pane.eye { --tick: var(--faint); } .dsc-pane.agent { --tick: var(--accent); } .dsc-pane.crawler { --tick: #7c6bff; } +.dsc-pane > header { display: flex; align-items: baseline; gap: 10px; padding: 13px 16px 10px; border-bottom: 1px solid var(--border); } +.dsc-pane > header .k { font-family: var(--disp); font-weight: 700; font-size: 14.5px; color: var(--ink); } +.dsc-pane > header .sub { font-size: 12px; color: var(--muted); } +.dsc-pane .body { padding: 14px 16px; flex: 1; min-height: 0; display: flex; flex-direction: column; } +.dsc-pane .body.scroll { overflow: auto; } + +.dsc-svc { padding: 11px 0; border-bottom: 1px solid var(--border); flex: 0 0 auto; } +.dsc-svc:last-child { border-bottom: 0; } +.dsc-svc .top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; } +.dsc-svc h4 { margin: 0; font-size: 14.5px; font-weight: 700; color: var(--ink); } +.dsc-svc .prov { font-size: 12px; color: var(--muted); font-family: var(--mono); white-space: nowrap; } +.dsc-svc .desc { font-size: 12.8px; color: var(--ink-soft); margin: 5px 0 8px; line-height: 1.45; } +.dsc-svc .meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.dsc-svc .rail { font-family: var(--mono); font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; + border: 1px solid currentColor; } +.dsc-svc .rail.x402 { color: var(--dsc-x402); } .dsc-svc .rail.mpp { color: var(--pay-ink); } +.dsc-svc .tagpill { font-family: var(--mono); font-size: 10.5px; color: var(--muted); background: var(--panel); + border: 1px solid var(--border); padding: 2px 7px; border-radius: 6px; } +.dsc-svc .price { font-family: var(--mono); font-size: 11.5px; color: var(--ink); } + +.dsc-tabs { display: flex; gap: 4px; margin-bottom: 10px; flex: none; } +.dsc-tab { font-family: var(--mono); font-size: 11.5px; color: var(--muted); background: var(--panel); + border: 1px solid var(--border); border-radius: 8px 8px 0 0; border-bottom: 0; padding: 6px 11px; cursor: pointer; } +.dsc-tab[data-on="1"] { color: var(--accent-ink); background: var(--accent-wash); border-color: var(--border-strong); } +.dsc-req { font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft); background: var(--panel); + border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; margin-bottom: 8px; white-space: pre-wrap; + word-break: break-word; flex: none; } + +.dsc-json { font-family: var(--mono); font-size: 11.5px; line-height: 1.55; margin: 0; color: var(--ink-soft); + background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 11px; overflow: auto; + white-space: pre-wrap; word-break: break-word; } +.dsc-json.grow { flex: 1 1 auto; min-height: 0; } +.dsc-json .jkey { color: var(--accent-ink); } .dsc-json .jstr { color: #0a7d53; } +.dsc-json .jnum { color: var(--pay-ink); } .dsc-json .jbool { color: #6d5cf0; } .dsc-json .jnull { color: var(--faint); } + +.dsc-empty { color: var(--muted); font-size: 12.8px; padding: 8px 0; } + +.dsc-facets { margin-top: 16px; background: var(--bg-tint); border: 1px solid var(--border); border-radius: 14px; padding: 18px 20px; } +.dsc-facets h3 { font-family: var(--disp); font-weight: 800; font-size: 17px; letter-spacing: -0.02em; color: var(--ink); } +.dsc-facets .lead { color: var(--ink-soft); font-size: 13.5px; margin: 4px 0 16px; max-width: 74ch; } +.dsc-facet-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; } +.dsc-facet h5 { margin: 0 0 9px; font-family: var(--mono); font-size: 11.5px; color: var(--muted); font-weight: 500; } +.dsc-bar { display: grid; grid-template-columns: 108px 1fr auto; gap: 10px; align-items: center; margin: 5px 0; font-size: 12px; } +.dsc-bar .name { font-family: var(--mono); color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dsc-bar .track { height: 9px; background: var(--panel-2); border-radius: 6px; overflow: hidden; } +.dsc-bar .fill { height: 100%; border-radius: 6px; background: var(--faint); transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1); } +.dsc-bar .fill.x402 { background: var(--dsc-x402); } .dsc-bar .fill.mpp { background: var(--dsc-mpp); } +.dsc-bar .val { font-family: var(--mono); color: var(--ink); font-variant-numeric: tabular-nums; } +.dsc .runnote a { color: var(--accent-ink); display: inline-flex; align-items: center; gap: 4px; } +.dsc .runnote a:hover { text-decoration: underline; } + +/* discover tier β€” its own dot (teal) + badge, additive to live/recap */ +.tier.discover { color: #fff; background: var(--accent); } +.tdot.discover { background: var(--accent); } + +@media (max-width: 860px) { + .dsc-altitudes { grid-template-columns: 1fr; } + .dsc-facet-grid { grid-template-columns: 1fr; } + .dsc-tier { margin-left: 0; } +} +@media (prefers-reduced-motion: reduce) { .dsc-bar .fill { transition: none; } } + footer { border-top: 1px solid var(--border); padding: 26px 0; color: var(--faint); font-size: 12.5px; margin-top: 50px; } footer .bar { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; } diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx index 6236138c..89b3c92a 100644 --- a/showcase/app/t/[slug]/page.tsx +++ b/showcase/app/t/[slug]/page.tsx @@ -6,6 +6,7 @@ import { PROTOCOL_LABEL, LANGUAGE_LABEL } from "@/lib/types"; import { repoUrl, repoFileUrl } from "@/lib/repo"; import LiveRunPanel from "@/components/LiveRunPanel"; import RecapPanel from "@/components/RecapPanel"; +import DiscoverPanel from "@/components/DiscoverPanel"; import CodeBlock from "@/components/CodeBlock"; import { ArrowRight, ArrowLeft, GitHub, External } from "@/components/icons"; @@ -47,7 +48,11 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug: {PROTOCOL_LABEL[t.protocol]} {LANGUAGE_LABEL[t.language]} - {isRecap ? "recap Β· watch it run" : "live Β· you pay per call"} + {t.tier === "recap" + ? "recap Β· watch it run" + : t.tier === "discover" + ? "discover Β· free & live" + : "live Β· you pay per call"}

{t.title}

@@ -157,6 +162,8 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug: {t.run.kind === "live" ? ( + ) : t.run.kind === "discover" ? ( + ) : ( )} diff --git a/showcase/components/DiscoverPanel.tsx b/showcase/components/DiscoverPanel.tsx new file mode 100644 index 00000000..4fc81af3 --- /dev/null +++ b/showcase/components/DiscoverPanel.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { DiscoverRun } from "@/lib/types"; +import { External } from "./icons"; + +// The functional "See it run" for the discovery tutorial: one question, answered at three +// altitudes (by eye / by an agent / by a crawler) live against the real, public Nevermined +// catalog through /api/catalog. No credentials, no payment β€” every call is read-only and free. +// Logic ported from the standalone playground (catalog/discover-the-catalog/playground). + +type Rail = "x402" | "mpp"; +type Tab = "mcp" | "ard" | "rest"; + +interface Stats { + tier: string; + total: number | null; + categories: number | null; + x402: number | null; + mpp: number | null; +} +interface Service { + slug?: string; + title?: string; + provider?: string; + shortDescription?: string; + protocol?: string; + category?: string; + priceLabel?: string; + endpoints?: { priceLabel?: string }[]; +} +interface SearchResult { + keyword: string; + rest: { req: string; res: { services?: Service[] } }; + ard: { req: string; res: unknown }; + mcp: { req: string; res: unknown }; + feed: { entry: unknown }; +} +interface Facets { + [field: string]: { buckets: { value: string; count: number }[] } | undefined; +} + +// tiny JSON syntax highlighter β€” the data is the content here, so make it readable. +// Escapes & and < first, so the highlighted string is safe to inject. +function hlJson(obj: unknown): string { + const j = JSON.stringify(obj, null, 2) ?? "null"; + return j + .replace(/&/g, "&") + .replace(/ + colon ? `${m}` : `${m}`, + ) + .replace(/\b(-?\d+\.?\d*)\b/g, '$1') + .replace(/\b(true|false)\b/g, '$1') + .replace(/\bnull\b/g, 'null'); +} + +const TABS: { id: Tab; label: string }[] = [ + { id: "mcp", label: "MCP tool" }, + { id: "ard", label: "ARD /search" }, + { id: "rest", label: "REST" }, +]; + +// pay:protocol β†’ rail class + display; type β†’ strip the application/ prefix; tags β†’ as-is +const FACET_SPEC: { + field: string; + label: string; + rail?: boolean; + fmt: (v: string) => string; +}[] = [ + { field: "pay:protocol", label: "payment rail", rail: true, fmt: (v) => (v === "x402" ? "x402" : v === "mpp" ? "MPP" : v) }, + { field: "type", label: "media type", fmt: (v) => v.replace("application/", "") }, + { field: "tags", label: "top tags", fmt: (v) => v }, +]; + +async function callApi(op: string, body?: Record) { + const res = await fetch("/api/catalog", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ op, ...body }), + }); + return res.json(); +} + +export default function DiscoverPanel({ run }: { run: DiscoverRun }) { + const [stats, setStats] = useState(null); + const [query, setQuery] = useState(run.question); + const [rails, setRails] = useState([]); + const [tab, setTab] = useState("mcp"); + const [result, setResult] = useState(null); + const [facets, setFacets] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const discover = useCallback( + async (text: string, useRails: Rail[]) => { + const q = text.trim() || run.question; + setBusy(true); + setError(null); + try { + const [s, e] = await Promise.all([ + callApi("search", { text: q, rails: useRails }), + callApi("explore", { text: q }), + ]); + if (s?.error) setError(s.message || "The catalog didn't answer β€” try again."); + else setResult(s as SearchResult); + setFacets((e?.facets ?? {}) as Facets); + } catch { + setError("Network error reaching the catalog. Try again."); + } finally { + setBusy(false); + } + }, + [run.question], + ); + + // boot: live stats, then auto-run the hero question so the panel is populated on first paint + useEffect(() => { + let live = true; + callApi("boot").then((b) => { + if (live && b?.stats) setStats({ tier: b.tier, ...b.stats }); + }); + discover(run.question, []); + return () => { + live = false; + }; + }, [discover, run.question]); + + function toggleRail(r: Rail) { + const next = rails.includes(r) ? rails.filter((x) => x !== r) : [...rails, r]; + setRails(next); + discover(query, next); + } + + const services = result?.rest?.res?.services ?? []; + const active = result ? result[tab] : null; + + return ( +
+ {/* guided-tour video β€” watch the concept, then run it live below */} + {run.video ? ( +
+ +
+ {run.video.caption} Β· {run.video.duration} +
+
+ ) : null} + + {/* readout */} +
+ + +
+ + {stats?.x402 ?? "β€”"} + / + {stats?.mpp ?? "β€”"} + + x402 / MPP rails +
+
+
+
+ + {/* command bar */} +
{ + e.preventDefault(); + discover(query, rails); + }} + > +
+ + setQuery(e.target.value)} + spellCheck={false} + autoComplete="off" + aria-label="Ask the catalog a question" + placeholder="which agents can enrich a company?" + /> +
+ +
+ + {/* rail filters + presets */} +
+ rails + {(["x402", "mpp"] as Rail[]).map((r) => ( + + ))} + try + {run.presets.map((p) => ( + + ))} +
+ + {error ?
{error}
: null} + + {/* three altitudes */} +
+ {/* By eye */} +
+
+ By eye + what a person browses +
+
+ {services.length === 0 ? ( +
+ {busy ? "Searching the catalog…" : "No matching agents β€” try another query or clear the rail filter."} +
+ ) : ( + services.slice(0, 8).map((s, i) => ( +
+
+

{s.title || s.slug}

+ {s.provider ? {s.provider} : null} +
+ {s.shortDescription ?
{s.shortDescription}
: null} +
+ {s.protocol === "x402" || s.protocol === "mpp" ? ( + {s.protocol === "x402" ? "x402" : "MPP"} + ) : null} + {s.category ? {s.category} : null} + {(() => { + const price = s.priceLabel || s.endpoints?.[0]?.priceLabel; + return price ? {price} : null; + })()} +
+
+ )) + )} +
+
+ + {/* By an agent */} +
+
+ By an agent + the live API call it makes +
+
+
+ {TABS.map((t) => ( + + ))} +
+
{active ? active.req : "The request appears here."}
+
+          
+
+ + {/* By a crawler */} +
+
+ By a crawler + .well-known/ard.json +
+
+ {result?.feed?.entry ? ( +
+            ) : (
+              
+ The open-web feed entry for the top match. Every listed agent appears at{" "} + /.well-known/ard.json. +
+ )} +
+
+
+ + {/* facet strip β€” the STRUCTURE behind the answer, via ARD /explore */} +
+

What's in this answer

+

+ The same query, run through ARD /explore β€” a live histogram of the payment rails, media + types and tags behind your results. This is how you discover the catalog's structure, not + just its listings. +

+
+ {facets && FACET_SPEC.some((f) => facets[f.field]) ? ( + FACET_SPEC.map((spec) => { + const f = facets[spec.field]; + if (!f) return null; + const max = Math.max(1, ...f.buckets.map((b) => b.count)); + return ( +
+
{spec.label}
+ {f.buckets.map((b) => ( +
+
+ {spec.fmt(b.value)} +
+
+
+
+
{b.count}
+
+ ))} +
+ ); + }) + ) : ( +
{busy ? "Computing facets…" : "Run a query to see its facets."}
+ )} +
+
+ +

+ Live against the public Nevermined Agent Services Catalog β€” every call here is read-only, unauthenticated + and free (no API key, no wallet, no payment). A same-origin proxy (/api/catalog) forwards the + calls server-side because the catalog's CORS only allows *.nevermined.app origins.{" "} + + Browse the catalog + {" "} + {run.note} +

+
+ ); +} + +function Stat({ value, label }: { value: number | null | undefined; label: string }) { + return ( +
+ {value ?? "β€”"} + {label} +
+ ); +} diff --git a/showcase/content/tutorials.ts b/showcase/content/tutorials.ts index f7739aa1..57e6cade 100644 --- a/showcase/content/tutorials.ts +++ b/showcase/content/tutorials.ts @@ -561,6 +561,82 @@ weather.ensureCity # prompt β€” guide the LLM to request weather`, }, }, + // ───────────────────── Catalog Β· discovery (functional, free) ────────────── + { + slug: "discover-the-catalog", + title: "What's in the Catalog?", + tagline: + "The half that comes before payment: the same live catalog of pay-per-use AI agents, read three ways β€” by eye, by an agent, and by a crawler. Public, unauthenticated, and free to read.", + protocol: "catalog", + language: "ts", + tier: "discover", + repoPath: "catalog/discover-the-catalog/", + learn: { + lead: "Before an agent can pay for a service, it has to find it β€” so the catalog is built to be discovered by machines, not just browsed by people.", + bullets: [ + "Browse the catalog by eye on the website β€” filter by category and by payment rail", + "Query it as an agent over REST, the Catalog MCP (search_services), and the ARD registry", + "Ingest the whole catalog as a crawler from one standards-compliant feed (/.well-known/ard.json)", + "Read the structure behind an answer with ARD /explore β€” the rails, media types and tags, not just names", + "Every discovery call is read-only and needs no API key, no wallet, and no payment β€” ever", + ], + }, + how: { + paragraphs: [ + 'One question β€” "which agents can enrich a company?" β€” answered at three altitudes over the same data: a website a person browses, an API (and an MCP tool) an agent queries at runtime, and a single feed any registry or crawler can ingest.', + "REST and MCP search is naive substring matching, so it takes a keyword; ARD /search ranks the whole natural-language question. That difference is the lesson, not a bug β€” and ARD /explore turns the same query into a live histogram of the catalog's shape.", + ], + table: { + head: ["Altitude", "Who it's for", "Surface"], + rows: [ + ["By eye", "a person", "nevermined.app/catalog"], + ["By an agent", "your code / your agent", "REST Β· Catalog MCP Β· ARD /search + /explore"], + ["By a crawler", "any registry / the open web", "GET /.well-known/ard.json"], + ], + }, + }, + tech: { + stack: ["REST catalog API", "Catalog MCP", "ARD registry", "/.well-known/ard.json", "public Β· no key"], + samples: [ + { + caption: "ARD /search ranks the whole question β€” the body is NESTED (a bare {text:…} 500s)", + lang: "bash", + code: `curl -s -X POST "$API/api/v1/ard/search" \\ + -H 'content-type: application/json' -d '{ + "query": { "text": "which agents can enrich a company?", + "filter": { "pay:protocol": ["x402"] } }, + "pageSize": 5 }'`, + }, + { + caption: "point any MCP client at the hosted server β€” no key needed for discovery", + lang: "bash", + code: `claude mcp add --transport http nevermined \\ + https://mcp.live.nevermined.app/mcp`, + }, + ], + files: [ + { path: "README.md", desc: "the layered tutorial β€” every curl / MCP / ARD example, verified live" }, + { path: "run-demo.sh", desc: "a free, no-key discovery tour of every surface from your terminal" }, + { path: "playground/", desc: "the standalone, zero-dependency version of the panel on this page" }, + ], + }, + run: { + kind: "discover", + video: { + src: "/media/discover-the-catalog/discover-the-catalog.mp4", + subtitles: [ + { src: "/media/discover-the-catalog/discover-the-catalog.en.vtt", srcLang: "en", label: "English", default: true }, + { src: "/media/discover-the-catalog/discover-the-catalog.es.vtt", srcLang: "es", label: "EspaΓ±ol" }, + ], + caption: "discover-the-catalog.mp4 Β· EN/ES subtitles", + duration: "~57s", + }, + question: "which agents can enrich a company?", + presets: ["enrich a company", "weather", "crypto prices", "web scraping", "person research"], + note: "The same three-altitude discovery runs live in the panel below; the standalone playground (catalog/discover-the-catalog/playground) is the runnable reference you can host yourself.", + }, + }, + // ─────────────────────────────── 9. Song (recap) ────────────────────────── { slug: "song-from-the-headlines", diff --git a/showcase/lib/catalog-discovery.mjs b/showcase/lib/catalog-discovery.mjs new file mode 100644 index 00000000..5aa94d15 --- /dev/null +++ b/showcase/lib/catalog-discovery.mjs @@ -0,0 +1,169 @@ +// Pure discovery logic behind the "discover" tutorial's See-it-run panel. +// No HTTP, no React β€” just request builders + response parsers + the feed matcher, +// so it's unit-testable and app/api/catalog/route.ts stays a thin fetch shim. +// Ported from catalog/discover-the-catalog/playground (the proven standalone tool). +// +// Self-check: `node lib/catalog-discovery.mjs`. + +// The catalog REST/MCP search is naive substring matching β€” it wants a keyword, not a +// sentence. ARD /search ranks the whole question. So the human cards + MCP pane search by +// the salient keyword; the ARD pane gets the full natural-language text. That difference is +// the lesson, not a bug. +const STOP = new Set( + ("which what who whom whose a an the can could would will to for of in on at and " + + "or is are be do does me my i you your with that this these those find show get list any all " + + "agent agents tool tools service services api apis paid pay please help need want") + .split(/\s+/), +); + +/** The salient domain noun from a natural-language question (longest non-stopword). */ +export function keyword(text) { + const words = String(text || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP.has(w)); + if (!words.length) return String(text || "").trim().replace(/[^a-z0-9\s]/gi, "").trim(); + return words.sort((a, b) => b.length - a.length)[0]; +} + +/** Only x402 and mpp are payable through the Router; ignore anything else a caller sends. */ +export function normalizeRails(rails) { + return [...new Set((rails || []).filter((r) => r === "x402" || r === "mpp"))]; +} + +// ── request builders: each returns { path|body, display } β€” `display` is the exact call +// shown in the panel, so what the reader sees is what the server actually sends. ────────── + +export function restRequest(kw, rails) { + const qs = new URLSearchParams({ search: kw, offset: "8" }); + if (rails.length === 1) qs.set("protocol", rails[0]); + const path = `/api/v1/catalog/services?${qs}`; + return { path, display: `GET ${path}` }; +} + +export function ardRequest(text, rails) { + const filter = rails.length ? { "pay:protocol": rails } : undefined; + const body = { query: { text, ...(filter ? { filter } : {}) }, pageSize: 6 }; + return { path: "/api/v1/ard/search", body, display: `POST /api/v1/ard/search\n${JSON.stringify(body)}` }; +} + +export function mcpRequest(kw, rails) { + const args = { query: kw, offset: 6, ...(rails.length === 1 ? { protocol: rails[0] } : {}) }; + const body = { jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: "search_services", arguments: args } }; + return { path: "/mcp", body, display: `search_services(${JSON.stringify(args)})` }; +} + +export function exploreRequest(text) { + const body = { + query: { text }, + resultType: { facets: [{ field: "pay:protocol" }, { field: "type" }, { field: "tags", limit: 6 }] }, + }; + return { path: "/api/v1/ard/explore", body }; +} + +/** MCP streamable-HTTP may answer as JSON or as an SSE stream β€” pull the JSON out of both. */ +export function parseUpstream(text) { + const trimmed = String(text || "").trim(); + try { + return JSON.parse(trimmed); + } catch { + // SSE framing: one or more `data: {...}` lines. Take the last data payload. + const datas = trimmed + .split(/\r?\n/) + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()); + for (const d of datas.reverse()) { + try { + return JSON.parse(d); + } catch { + /* try the next */ + } + } + return { error: "unparseable_upstream" }; + } +} + +/** The MCP tool wraps its JSON payload as a string inside result.content[0].text. */ +export function parseMcpResult(raw) { + try { + return JSON.parse(raw.result.content[0].text); + } catch { + return raw; + } +} + +/** Trim a feed entry to the meaningful, agent-ready terms the panel shows. */ +export function trimFeedEntry(entry) { + if (!entry) return null; + return { + identifier: entry.identifier, + displayName: entry.displayName, + type: entry.type, + url: entry.url, + description: entry.description, + tags: entry.tags, + representativeQueries: entry.representativeQueries, + "nvm:catalog": entry["nvm:catalog"], + trustManifest: entry.trustManifest, + }; +} + +/** Match the top human/ARD result against the crawler feed (by name, then by id). */ +export function matchFeedEntry(entries, restRes, ardRes) { + const list = Array.isArray(entries) ? entries : []; + const topName = restRes?.services?.[0]?.title || ardRes?.results?.[0]?.displayName; + let entry = null; + if (topName) entry = list.find((e) => (e.displayName || "").toLowerCase() === topName.toLowerCase()); + if (!entry && ardRes?.results?.[0]) { + const id = ardRes.results[0].identifier; + entry = list.find((e) => e.identifier === id); + } + return trimFeedEntry(entry); +} + +// ── runnable self-check ────────────────────────────────────────────────────── +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) { + const assert = (c, m) => { + if (!c) throw new Error("FAIL: " + m); + }; + + // keyword: picks the longest salient noun, drops stopwords ("agents","company enrich") + assert(keyword("which agents can enrich a company?") === "company", "keyword β†’ company"); + assert(keyword("weather") === "weather", "single word passes through"); + assert(keyword("???") === "", "no salient word β†’ empty"); + + // rails filter only attaches protocol on a single rail (REST/MCP), always on ARD + assert(!restRequest("x", ["x402", "mpp"]).path.includes("protocol"), "two rails β†’ no REST protocol filter"); + assert(restRequest("x", ["x402"]).path.includes("protocol=x402"), "one rail β†’ REST protocol filter"); + assert(mcpRequest("x", ["mpp"]).body.params.arguments.protocol === "mpp", "one rail β†’ MCP protocol"); + assert(ardRequest("q", ["x402"]).body.query.filter["pay:protocol"][0] === "x402", "ARD filter attached"); + assert(ardRequest("q", []).body.query.filter === undefined, "no rails β†’ no ARD filter"); + + // explore body shape is the one the API requires (bare {text} 500s) + const ex = exploreRequest("enrichment").body; + assert(ex.query.text === "enrichment" && Array.isArray(ex.resultType.facets), "explore body shape"); + + // normalizeRails drops junk + dedupes + assert(JSON.stringify(normalizeRails(["x402", "rest", "x402", "mpp"])) === '["x402","mpp"]', "normalizeRails"); + + // parseUpstream handles plain JSON and SSE framing + assert(parseUpstream('{"a":1}').a === 1, "plain json"); + assert(parseUpstream('event: message\ndata: {"a":2}\n\n').a === 2, "sse framed json"); + + // parseMcpResult unwraps the stringified tool payload + assert(parseMcpResult({ result: { content: [{ text: '{"total":3}' }] } }).total === 3, "mcp unwrap"); + + // matchFeedEntry: by name, then by identifier, trimmed + const entries = [ + { identifier: "urn:a", displayName: "Alpha", type: "application/json", extra: "dropped" }, + { identifier: "urn:b", displayName: "Beta" }, + ]; + const m = matchFeedEntry(entries, { services: [{ title: "alpha" }] }, null); + assert(m.identifier === "urn:a" && m.extra === undefined, "match by name + trim"); + const m2 = matchFeedEntry(entries, {}, { results: [{ identifier: "urn:b", displayName: "Beta" }] }); + assert(m2.identifier === "urn:b", "match by identifier"); + assert(matchFeedEntry(entries, {}, { results: [{ identifier: "urn:zzz" }] }) === null, "no match β†’ null"); + + console.log("βœ“ catalog-discovery self-check passed"); +} diff --git a/showcase/lib/types.ts b/showcase/lib/types.ts index 8a3cbff2..ad03b227 100644 --- a/showcase/lib/types.ts +++ b/showcase/lib/types.ts @@ -3,7 +3,7 @@ export type Protocol = "x402" | "mcp" | "langchain" | "catalog"; export type Language = "ts" | "py" | "autonomous"; -export type Tier = "live" | "recap"; +export type Tier = "live" | "recap" | "discover"; export interface CodeSample { caption?: string; @@ -92,6 +92,29 @@ export interface RecapRun { takes?: { label: string; byline?: string; embedHref: string }[]; } +/** A captioned video block (WebVTT tracks β€” HTML5 only accepts .vtt). */ +export interface VideoBlock { + src: string; + caption: string; + duration: string; + subtitles?: { src: string; srcLang: string; label: string; default?: boolean }[]; +} + +/** Section 4 (discover) β€” a functional, read-only discovery panel. Unlike `live` (which + * runs the x402 payment handshake against a sandbox), this queries the real, public catalog + * live through /api/catalog (a same-origin proxy) β€” no credentials, no payment, ever. The + * panel (components/DiscoverPanel) fetches everything else; only these editorial bits live here. */ +export interface DiscoverRun { + kind: "discover"; + /** guided-tour video shown above the live panel */ + video?: VideoBlock; + /** the hero question, auto-run on load so the panel is populated on first paint */ + question: string; + /** preset chips offered under the query box */ + presets: string[]; + note: string; +} + export interface Tutorial { slug: string; title: string; @@ -104,7 +127,7 @@ export interface Tutorial { learn: LearnSection; how: HowSection; tech: TechSection; - run: LiveRun | RecapRun; + run: LiveRun | RecapRun | DiscoverRun; } export const PROTOCOL_LABEL: Record = { diff --git a/showcase/public/media/discover-the-catalog/discover-the-catalog.en.srt b/showcase/public/media/discover-the-catalog/discover-the-catalog.en.srt new file mode 100644 index 00000000..33b7b9ac --- /dev/null +++ b/showcase/public/media/discover-the-catalog/discover-the-catalog.en.srt @@ -0,0 +1,34 @@ +1 +00:00:01,200 --> 00:00:06,499 +This is the Nevermined Catalog. A directory +of AI agents you can pay for, per call. + +2 +00:00:11,500 --> 00:00:18,252 +By eye, you just browse it. Filter by category, +or by the payment rail you can use. + +3 +00:00:20,500 --> 00:00:26,189 +Open one, and you see exactly how it's paid. +A rail, a network, a price per call. + +4 +00:00:29,000 --> 00:00:34,505 +By an agent, the same catalog answers in code. +Ask what can enrich a company. + +5 +00:00:34,800 --> 00:00:41,447 +It lists them, grouped by rail. +Discovery only β€” nothing paid yet. + +6 +00:00:42,300 --> 00:00:47,024 +By a crawler, the whole catalog is one open +feed any registry can read. + +7 +00:00:51,000 --> 00:00:56,068 +Find first. Pay per call second. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/discover-the-catalog/discover-the-catalog.en.vtt b/showcase/public/media/discover-the-catalog/discover-the-catalog.en.vtt new file mode 100644 index 00000000..57695f64 --- /dev/null +++ b/showcase/public/media/discover-the-catalog/discover-the-catalog.en.vtt @@ -0,0 +1,36 @@ +WEBVTT + +1 +00:00:01.200 --> 00:00:06.499 +This is the Nevermined Catalog. A directory +of AI agents you can pay for, per call. + +2 +00:00:11.500 --> 00:00:18.252 +By eye, you just browse it. Filter by category, +or by the payment rail you can use. + +3 +00:00:20.500 --> 00:00:26.189 +Open one, and you see exactly how it's paid. +A rail, a network, a price per call. + +4 +00:00:29.000 --> 00:00:34.505 +By an agent, the same catalog answers in code. +Ask what can enrich a company. + +5 +00:00:34.800 --> 00:00:41.447 +It lists them, grouped by rail. +Discovery only β€” nothing paid yet. + +6 +00:00:42.300 --> 00:00:47.024 +By a crawler, the whole catalog is one open +feed any registry can read. + +7 +00:00:51.000 --> 00:00:56.068 +Find first. Pay per call second. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/discover-the-catalog/discover-the-catalog.es.srt b/showcase/public/media/discover-the-catalog/discover-the-catalog.es.srt new file mode 100644 index 00000000..2b7f944d --- /dev/null +++ b/showcase/public/media/discover-the-catalog/discover-the-catalog.es.srt @@ -0,0 +1,34 @@ +1 +00:00:01,200 --> 00:00:06,499 +Este es el catΓ‘logo de Nevermined. Un directorio +de agentes de IA que pagas por llamada. + +2 +00:00:11,500 --> 00:00:18,252 +A simple vista, lo exploras. Filtra por categorΓ­a, +o por el raΓ­l de pago que puedes usar. + +3 +00:00:20,500 --> 00:00:26,189 +Abre uno y ves exactamente cΓ³mo se paga. +Un raΓ­l, una red, un precio por llamada. + +4 +00:00:29,000 --> 00:00:34,505 +Por un agente, el catΓ‘logo responde en cΓ³digo. +Pregunta quΓ© puede enriquecer una empresa. + +5 +00:00:34,800 --> 00:00:41,447 +Y los lista, agrupados por raΓ­l. +Solo descubrimiento, nada pagado aΓΊn. + +6 +00:00:42,300 --> 00:00:47,024 +Por un rastreador, todo el catΓ‘logo es un feed +abierto que cualquier registro puede leer. + +7 +00:00:51,000 --> 00:00:56,068 +Primero encuentra. Luego paga, por llamada. +Explora el catΓ‘logo de Nevermined. diff --git a/showcase/public/media/discover-the-catalog/discover-the-catalog.es.vtt b/showcase/public/media/discover-the-catalog/discover-the-catalog.es.vtt new file mode 100644 index 00000000..7729d3e6 --- /dev/null +++ b/showcase/public/media/discover-the-catalog/discover-the-catalog.es.vtt @@ -0,0 +1,36 @@ +WEBVTT + +1 +00:00:01.200 --> 00:00:06.499 +Este es el catΓ‘logo de Nevermined. Un directorio +de agentes de IA que pagas por llamada. + +2 +00:00:11.500 --> 00:00:18.252 +A simple vista, lo exploras. Filtra por categorΓ­a, +o por el raΓ­l de pago que puedes usar. + +3 +00:00:20.500 --> 00:00:26.189 +Abre uno y ves exactamente cΓ³mo se paga. +Un raΓ­l, una red, un precio por llamada. + +4 +00:00:29.000 --> 00:00:34.505 +Por un agente, el catΓ‘logo responde en cΓ³digo. +Pregunta quΓ© puede enriquecer una empresa. + +5 +00:00:34.800 --> 00:00:41.447 +Y los lista, agrupados por raΓ­l. +Solo descubrimiento, nada pagado aΓΊn. + +6 +00:00:42.300 --> 00:00:47.024 +Por un rastreador, todo el catΓ‘logo es un feed +abierto que cualquier registro puede leer. + +7 +00:00:51.000 --> 00:00:56.068 +Primero encuentra. Luego paga, por llamada. +Explora el catΓ‘logo de Nevermined. diff --git a/showcase/scripts/sync-media.sh b/showcase/scripts/sync-media.sh index 324d05cb..1dc682c4 100755 --- a/showcase/scripts/sync-media.sh +++ b/showcase/scripts/sync-media.sh @@ -28,6 +28,7 @@ if [ ! -d "$catalog" ]; then exit 1 fi +copy catalog discover-the-catalog copy catalog song-from-the-headlines copy catalog diligence-in-a-box echo "done."