@@ -1329,6 +1329,245 @@ def _apply_resource(collection_path, name, resource, token, ssl_ctx):
13291329 raise RuntimeError (f"K8s API error { e .code } for { name } " ) from e
13301330
13311331
1332+ def _build_skill_md (items , base_url ):
1333+ """Build /skill.md content from all Ready ServiceOffer items."""
1334+ ready = []
1335+ for item in items :
1336+ conditions = item .get ("status" , {}).get ("conditions" , [])
1337+ if is_condition_true (conditions , "Ready" ):
1338+ ready .append (item )
1339+
1340+ agent_name = "Obol Stack"
1341+ if ready :
1342+ reg = ready [0 ].get ("spec" , {}).get ("registration" , {})
1343+ if reg .get ("name" ):
1344+ agent_name = reg ["name" ]
1345+
1346+ lines = [
1347+ f"# { agent_name } — x402 Service Catalog\n " ,
1348+ "" ,
1349+ "> This document lists all payment-gated services on this node." ,
1350+ "> Payment uses the [x402 protocol](https://www.x402.org/) with USDC stablecoin." ,
1351+ "> For machine-readable agent identity, see [/.well-known/agent-registration.json](/.well-known/agent-registration.json)." ,
1352+ "" ,
1353+ ]
1354+
1355+ if not ready :
1356+ lines .append ("**No services currently available.**\n " )
1357+ return "\n " .join (lines )
1358+
1359+ lines .append ("## Services\n " )
1360+ lines .append ("| Service | Type | Model | Price | Endpoint |" )
1361+ lines .append ("|---------|------|-------|-------|----------|" )
1362+ for item in ready :
1363+ spec = item .get ("spec" , {})
1364+ name = item ["metadata" ]["name" ]
1365+ offer_type = spec .get ("type" , "http" )
1366+ model_name = spec .get ("model" , {}).get ("name" , "—" )
1367+ path = spec .get ("path" , f"/services/{ name } " )
1368+ price_desc = describe_price (spec )
1369+ lines .append (f"| [{ name } ](#{ name } ) | { offer_type } | { model_name } | { price_desc } | `{ base_url } { path } ` |" )
1370+ lines .append ("" )
1371+
1372+ lines .append ("## How to Pay (x402 Protocol)\n " )
1373+ lines .append ("1. **Send a normal HTTP request** to the service endpoint" )
1374+ lines .append ("2. **Receive HTTP 402** with `X-Payment` response header containing JSON pricing:" )
1375+ lines .append (" ```json" )
1376+ lines .append (' {"x402Version":1,"schemes":[{"scheme":"exact","network":"...","maxAmountRequired":"...","payTo":"0x...","extra":{"name":"USDC","version":"2"}}]}' )
1377+ lines .append (" ```" )
1378+ lines .append ("3. **Sign an ERC-3009 `transferWithAuthorization`** for USDC on the specified network:" )
1379+ lines .append (" - `from`: your wallet address" )
1380+ lines .append (" - `to`: the `payTo` address from the 402 response" )
1381+ lines .append (" - `value`: the `maxAmountRequired` (in smallest units, 6 decimals)" )
1382+ lines .append (" - `validAfter`: 0" )
1383+ lines .append (" - `validBefore`: current timestamp + timeout" )
1384+ lines .append (" - `nonce`: random 32 bytes" )
1385+ lines .append ("4. **Retry the original request** with `X-Payment` header containing your signed authorization" )
1386+ lines .append ("5. **Receive 200** with the actual service response" )
1387+ lines .append ("" )
1388+ lines .append ("### Quick Example (curl)\n " )
1389+ lines .append ("```bash" )
1390+ lines .append ("# Step 1: Probe for pricing" )
1391+ first_spec = ready [0 ].get ("spec" , {})
1392+ first_path = first_spec .get ("path" , f"/services/{ ready [0 ]['metadata' ]['name' ]} " )
1393+ lines .append (f'curl -s -o /dev/null -w "%{{http_code}}" { base_url } { first_path } /v1/chat/completions' )
1394+ lines .append ("# Returns: 402" )
1395+ lines .append ("" )
1396+ lines .append ("# Step 2: Get pricing details" )
1397+ lines .append (f'curl -sI { base_url } { first_path } /v1/chat/completions | grep X-Payment' )
1398+ lines .append ("```" )
1399+ lines .append ("" )
1400+ lines .append ("For programmatic payment, use [x402-go](https://github.com/coinbase/x402/tree/main/go), [x402-js](https://github.com/coinbase/x402/tree/main/typescript), or sign ERC-3009 directly with ethers/viem/web3.py." )
1401+ lines .append ("" )
1402+
1403+ lines .append ("## Service Details\n " )
1404+ for item in ready :
1405+ spec = item .get ("spec" , {})
1406+ name = item ["metadata" ]["name" ]
1407+ offer_type = spec .get ("type" , "http" )
1408+ model_name = spec .get ("model" , {}).get ("name" )
1409+ path = spec .get ("path" , f"/services/{ name } " )
1410+ registration = spec .get ("registration" , {})
1411+ default_desc = f"x402 payment-gated { offer_type } service"
1412+ if model_name :
1413+ default_desc = f"{ model_name } inference via x402 micropayments"
1414+
1415+ lines .append (f"### { name } \n " )
1416+ lines .append (f"- **Endpoint**: `{ base_url } { path } `" )
1417+ lines .append (f"- **Type**: { offer_type } " )
1418+ if model_name :
1419+ lines .append (f"- **Model**: { model_name } " )
1420+ lines .append (f"- **Price**: { describe_price (spec )} " )
1421+ lines .append (f"- **Pay To**: `{ get_pay_to (spec )} `" )
1422+ lines .append (f"- **Network**: { get_network (spec )} " )
1423+ lines .append (f"- **Description**: { registration .get ('description' , default_desc )} " )
1424+ if offer_type == "inference" and model_name :
1425+ lines .append (f"\n **OpenAI-compatible endpoint**: `POST { base_url } { path } /v1/chat/completions`" )
1426+ lines .append ("```json" )
1427+ lines .append ('{' )
1428+ lines .append (f' "model": "{ model_name } ",' )
1429+ lines .append (' "messages": [{"role": "user", "content": "Hello"}]' )
1430+ lines .append ('}' )
1431+ lines .append ("```" )
1432+ lines .append ("" )
1433+
1434+ lines .append ("## USDC Contract Addresses\n " )
1435+ lines .append ("| Network | Address |" )
1436+ lines .append ("|---------|---------|" )
1437+ lines .append ("| Base Sepolia | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |" )
1438+ lines .append ("| Base Mainnet | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |" )
1439+ lines .append ("" )
1440+ lines .append ("## Links\n " )
1441+ lines .append (f"- [Agent Registration](/.well-known/agent-registration.json)" )
1442+ lines .append ("- [x402 Protocol](https://www.x402.org/)" )
1443+ lines .append ("- [ERC-3009 (transferWithAuthorization)](https://eips.ethereum.org/EIPS/eip-3009)" )
1444+ lines .append ("- [ERC-8004 (Agent Identity)](https://eips.ethereum.org/EIPS/eip-8004)" )
1445+ lines .append ("" )
1446+
1447+ return "\n " .join (lines )
1448+
1449+
1450+ def _publish_skill_md (items , token , ssl_ctx ):
1451+ """Publish the /skill.md aggregate endpoint.
1452+
1453+ Creates four resources (no ownerReferences — aggregate, not per-offer):
1454+ 1. ConfigMap obol-skill-md — markdown content + httpd.conf
1455+ 2. Deployment obol-skill-md — busybox httpd serving the ConfigMap
1456+ 3. Service obol-skill-md — ClusterIP targeting the deployment
1457+ 4. HTTPRoute obol-skill-md-route — routes /skill.md to the Service
1458+ """
1459+ import hashlib
1460+
1461+ base_url = os .environ .get ("AGENT_BASE_URL" , "http://obol.stack:8080" )
1462+ _ , agent_ns = load_sa ()
1463+ content = _build_skill_md (items , base_url )
1464+ content_hash = hashlib .md5 (content .encode ()).hexdigest ()[:8 ]
1465+
1466+ cm_name = "obol-skill-md"
1467+ deploy_name = "obol-skill-md"
1468+ svc_name = "obol-skill-md"
1469+ route_name = "obol-skill-md-route"
1470+ labels = {"app" : deploy_name , "obol.org/managed-by" : "monetize" }
1471+
1472+ configmap = {
1473+ "apiVersion" : "v1" ,
1474+ "kind" : "ConfigMap" ,
1475+ "metadata" : {"name" : cm_name , "namespace" : agent_ns , "labels" : labels },
1476+ "data" : {
1477+ "skill.md" : content ,
1478+ "httpd.conf" : ".md:text/markdown\n " ,
1479+ },
1480+ }
1481+ _apply_resource (f"/api/v1/namespaces/{ agent_ns } /configmaps" , cm_name , configmap , token , ssl_ctx )
1482+
1483+ deployment = {
1484+ "apiVersion" : "apps/v1" ,
1485+ "kind" : "Deployment" ,
1486+ "metadata" : {"name" : deploy_name , "namespace" : agent_ns , "labels" : labels },
1487+ "spec" : {
1488+ "replicas" : 1 ,
1489+ "selector" : {"matchLabels" : labels },
1490+ "template" : {
1491+ "metadata" : {
1492+ "labels" : labels ,
1493+ "annotations" : {"obol.org/content-hash" : content_hash },
1494+ },
1495+ "spec" : {
1496+ "containers" : [
1497+ {
1498+ "name" : "httpd" ,
1499+ "image" : "busybox:1.36" ,
1500+ "command" : ["httpd" , "-f" , "-p" , "8080" , "-h" , "/www" ],
1501+ "ports" : [{"containerPort" : 8080 }],
1502+ "volumeMounts" : [
1503+ {"name" : "content" , "mountPath" : "/www" , "readOnly" : True },
1504+ {"name" : "httpdconf" , "mountPath" : "/etc/httpd.conf" , "subPath" : "httpd.conf" , "readOnly" : True },
1505+ ],
1506+ "resources" : {
1507+ "requests" : {"cpu" : "5m" , "memory" : "8Mi" },
1508+ "limits" : {"cpu" : "50m" , "memory" : "32Mi" },
1509+ },
1510+ }
1511+ ],
1512+ "volumes" : [
1513+ {
1514+ "name" : "content" ,
1515+ "configMap" : {
1516+ "name" : cm_name ,
1517+ "items" : [{"key" : "skill.md" , "path" : "skill.md" }],
1518+ },
1519+ },
1520+ {
1521+ "name" : "httpdconf" ,
1522+ "configMap" : {
1523+ "name" : cm_name ,
1524+ "items" : [{"key" : "httpd.conf" , "path" : "httpd.conf" }],
1525+ },
1526+ },
1527+ ],
1528+ },
1529+ },
1530+ },
1531+ }
1532+ _apply_resource (f"/apis/apps/v1/namespaces/{ agent_ns } /deployments" , deploy_name , deployment , token , ssl_ctx )
1533+
1534+ service = {
1535+ "apiVersion" : "v1" ,
1536+ "kind" : "Service" ,
1537+ "metadata" : {"name" : svc_name , "namespace" : agent_ns , "labels" : labels },
1538+ "spec" : {
1539+ "type" : "ClusterIP" ,
1540+ "selector" : labels ,
1541+ "ports" : [{"port" : 8080 , "targetPort" : 8080 , "protocol" : "TCP" }],
1542+ },
1543+ }
1544+ _apply_resource (f"/api/v1/namespaces/{ agent_ns } /services" , svc_name , service , token , ssl_ctx )
1545+
1546+ httproute = {
1547+ "apiVersion" : "gateway.networking.k8s.io/v1" ,
1548+ "kind" : "HTTPRoute" ,
1549+ "metadata" : {"name" : route_name , "namespace" : agent_ns },
1550+ "spec" : {
1551+ "parentRefs" : [
1552+ {"name" : "traefik-gateway" , "namespace" : "traefik" , "sectionName" : "web" }
1553+ ],
1554+ "rules" : [
1555+ {
1556+ "matches" : [{"path" : {"type" : "Exact" , "value" : "/skill.md" }}],
1557+ "backendRefs" : [{"name" : svc_name , "namespace" : agent_ns , "port" : 8080 }],
1558+ }
1559+ ],
1560+ },
1561+ }
1562+ _apply_resource (
1563+ f"/apis/gateway.networking.k8s.io/v1/namespaces/{ agent_ns } /httproutes" ,
1564+ route_name , httproute , token , ssl_ctx ,
1565+ )
1566+
1567+ ready_count = sum (1 for i in items if is_condition_true (i .get ("status" , {}).get ("conditions" , []), "Ready" ))
1568+ print (f" Published /skill.md ({ ready_count } service(s))" )
1569+
1570+
13321571def reconcile (ns , name , token , ssl_ctx ):
13331572 """Reconcile a single ServiceOffer through all stages."""
13341573 path = f"/apis/{ CRD_GROUP } /{ CRD_VERSION } /namespaces/{ ns } /{ CRD_PLURAL } /{ name } "
@@ -1608,6 +1847,10 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16081847
16091848 if not items :
16101849 print ("READY: 0/0 offers" if quick else "HEARTBEAT_OK: No ServiceOffers found" )
1850+ try :
1851+ _publish_skill_md ([], token , ssl_ctx )
1852+ except Exception as e :
1853+ print (f" Warning: skill.md publish failed: { e } " , file = sys .stderr )
16111854 return
16121855
16131856 pending = []
@@ -1618,6 +1861,10 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16181861
16191862 if not pending :
16201863 print (f"READY: { len (items )} /{ len (items )} offers" if quick else "HEARTBEAT_OK: All offers are Ready" )
1864+ try :
1865+ _publish_skill_md (items , token , ssl_ctx )
1866+ except Exception as e :
1867+ print (f" Warning: skill.md publish failed: { e } " , file = sys .stderr )
16211868 return
16221869
16231870 if quick :
@@ -1651,6 +1898,14 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16511898 reconcile (item_ns , item_name , token , ssl_ctx )
16521899 except Exception as e :
16531900 print (f" Error reconciling { item_ns } /{ item_name } : { e } " , file = sys .stderr )
1901+
1902+ # Regenerate /skill.md from current state of all offers.
1903+ try :
1904+ all_path = f"/apis/{ CRD_GROUP } /{ CRD_VERSION } /{ CRD_PLURAL } "
1905+ all_data = api_get (all_path , token , ssl_ctx )
1906+ _publish_skill_md (all_data .get ("items" , []), token , ssl_ctx )
1907+ except Exception as e :
1908+ print (f" Warning: skill.md publish failed: { e } " , file = sys .stderr )
16541909 else :
16551910 if not ns or not name :
16561911 print ("Error: --namespace and name are required (or use --all)" , file = sys .stderr )
0 commit comments