feat(agent-readiness): Agent Skills discovery index (#3310) (#3355)

* feat(agent-readiness): Agent Skills discovery index (#3310)

Closes #3310. Ships the Agent Skills Discovery v0.2.0 manifest at
/.well-known/agent-skills/index.json plus two real, useful skills.

Skills are grounded in real sebuf proto RPCs:
- fetch-country-brief → GetCountryIntelBrief (public).
- fetch-resilience-score → GetResilienceScore (Pro / API key).

Each SKILL.md documents endpoint, auth, parameters, response shape,
worked curl, errors, and when not to use the skill.

scripts/build-agent-skills-index.mjs walks every
public/.well-known/agent-skills/<name>/SKILL.md, sha256s the bytes,
and emits index.json. Wired into prebuild + every variant build so a
deploy can never ship an index whose digests disagree with served files.

tests/agent-skills-index.test.mjs asserts the index is up-to-date
via the script's --check mode and recomputes every sha256 against
the on-disk SKILL.md bytes.

Discovery wiring:
- public/.well-known/api-catalog: new anchor entry with the
  agent-skills-index rel per RFC 9727 linkset shape.
- vercel.json: adds agent-skills-index rel to the homepage +
  /index.html Link headers; deploy-config required-rels list updated.

Canonical URLs use the apex (worldmonitor.app) since #3322 fixed
the apex redirect that previously hid .well-known paths.

* fix(agent-readiness): correct auth header + harden frontmatter parser (#3310)

Addresses review findings on #3310.

## P1 — auth header was wrong in both SKILL.md files

The published skills documented `Authorization: Bearer wm_live_...`,
but WorldMonitor API keys must be sent in `X-WorldMonitor-Key`.
`Authorization: Bearer` is for MCP/OAuth or Clerk JWTs — not raw
`wm_live_...` keys. Agents that followed the SKILL.md verbatim would
have gotten 401s despite holding valid keys.

fetch-country-brief also incorrectly claimed the endpoint was
"public"; server-to-server callers without a trusted browser origin
are rejected by `validateApiKey`, so agents do need a key there too.
Fixed both SKILL.md files to document `X-WorldMonitor-Key` and
cross-link docs/usage-auth as the canonical auth matrix.

## P2 — frontmatter parser brittleness

The hand-rolled parser used `indexOf('\n---', 4)` as the closing
fence, which matched any body line that happened to start with `---`.
Swapped for a regex that anchors the fence to its own line, and
delegated value parsing to js-yaml (already a project dep) so future
catalog growth (quoted colons, typed values, arrays) does not trip
new edge cases.

Added parser-contract tests that lock in the new semantics:
body `---` does not terminate the block, values with colons survive
intact, non-mapping frontmatter throws, and no-frontmatter files
return an empty mapping.

Index.json rebuilt against the updated SKILL.md bytes.
This commit is contained in:
Elie Habib
2026-04-23 22:21:25 +04:00
committed by GitHub
parent 7cf0c32eaa
commit def94733a8
9 changed files with 427 additions and 8 deletions

View File

@@ -24,15 +24,16 @@
"build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/",
"build:pro": "cd pro-test && npm install && npm run build",
"build:openapi": "node -e \"require('fs').cpSync('docs/api/worldmonitor.openapi.yaml', 'public/openapi.yaml')\"",
"prebuild": "npm run build:openapi",
"build:agent-skills": "node scripts/build-agent-skills-index.mjs",
"prebuild": "npm run build:openapi && npm run build:agent-skills",
"build": "npm run build:blog && tsc && vite build",
"build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs",
"build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build",
"build:full": "npm run build:openapi && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
"build:tech": "npm run build:openapi && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
"build:finance": "npm run build:openapi && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
"build:happy": "npm run build:openapi && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
"build:commodity": "npm run build:openapi && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
"build:full": "npm run build:openapi && npm run build:agent-skills && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
"build:tech": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
"build:finance": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
"build:happy": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
"build:commodity": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
"typecheck": "tsc --noEmit",
"typecheck:api": "tsc --noEmit -p tsconfig.api.json",
"typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json",

View File

@@ -0,0 +1,81 @@
---
name: fetch-country-brief
version: 1
description: Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code.
---
# fetch-country-brief
Use this skill when the user asks for a summary of the current geopolitical, economic, or security situation in a specific country. The endpoint returns a fresh AI-generated brief composed from the latest news, market, conflict, and infrastructure signals World Monitor tracks for that country.
## Authentication
Server-to-server callers (agents, scripts, SDKs) MUST present an API key in the `X-WorldMonitor-Key` header. `Authorization: Bearer …` is for MCP/OAuth or Clerk JWTs — **not** raw API keys.
```
X-WorldMonitor-Key: wm_live_...
```
Browser requests from `worldmonitor.app` get a free pass via CORS Origin trust, but agents will never hit that path. Issue a key at https://www.worldmonitor.app/pro.
## Endpoint
```
GET https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief
```
## Parameters
| Name | In | Required | Shape | Notes |
|---|---|---|---|---|
| `country_code` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `US`, `IR`, `KE`) | Case-sensitive server-side. Lowercase is rejected with 400. |
| `framework` | query | no | free text, ≤ 2000 chars | Optional analytical framing appended to the system prompt (e.g. `"focus on energy security"`). |
## Response shape
```json
{
"countryCode": "IR",
"countryName": "Iran",
"brief": "Multi-paragraph AI-generated brief …",
"model": "gpt-4o-mini",
"generatedAt": 1745421600000
}
```
`generatedAt` is Unix epoch milliseconds. `model` identifies which LLM produced the text.
## Worked example
```bash
curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \
'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief?country_code=IR' \
| jq -r '.brief'
```
With an analytical framework:
```bash
curl -s --get -H "X-WorldMonitor-Key: $WM_API_KEY" \
'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief' \
--data-urlencode 'country_code=TR' \
--data-urlencode 'framework=focus on energy corridors and Black Sea shipping'
```
## Errors
- `400``country_code` missing, not 2 letters, or not uppercase.
- `401` — missing `X-WorldMonitor-Key` (server-to-server callers).
- `429` — rate limited; retry with backoff.
- `5xx` — transient upstream model failure; retry once after 2s.
## When NOT to use
- For rankings or comparisons across countries, use `fetch-resilience-score` per country and aggregate client-side, or call the `GetResilienceRanking` RPC directly.
- For raw news events rather than synthesized narrative, use `SearchGdeltDocuments` (`/api/intelligence/v1/search-gdelt-documents`).
## References
- OpenAPI: [IntelligenceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetCountryIntelBrief`.
- Auth matrix: https://www.worldmonitor.app/docs/usage-auth
- Documentation: https://www.worldmonitor.app/docs/documentation

View File

@@ -0,0 +1,87 @@
---
name: fetch-resilience-score
version: 1
description: Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country.
---
# fetch-resilience-score
Use this skill when the user asks how "resilient" a country is, or wants the numeric resilience score, trend, or per-domain breakdown. The score is a composite of economic, institutional, security, social, infrastructure, and environmental indicators, recomputed daily.
## Authentication — required
`/api/resilience/v1/get-resilience-score` is Pro-tier. Agents and other server-to-server callers MUST present an API key in the `X-WorldMonitor-Key` header. `Authorization: Bearer …` is for MCP/OAuth or Clerk JWTs — **not** raw API keys.
```
X-WorldMonitor-Key: wm_live_...
```
The key must be attached to a Pro subscription. Unauthenticated or free-tier requests return `401` / `403`. Issue a key at https://www.worldmonitor.app/pro.
## Endpoint
```
GET https://api.worldmonitor.app/api/resilience/v1/get-resilience-score
```
## Parameters
| Name | In | Required | Shape |
|---|---|---|---|
| `countryCode` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `DE`, `KE`, `BR`) |
## Response shape
```json
{
"countryCode": "DE",
"overallScore": 78.4,
"level": "HIGH",
"trend": "STABLE",
"change30d": -0.2,
"lowConfidence": false,
"imputationShare": 0.04,
"baselineScore": 79.1,
"stressScore": 78.4,
"stressFactor": 0.99,
"dataVersion": "2026-04-23",
"scoreInterval": { "lower": 76.1, "upper": 80.7 },
"domains": [ { "name": "Economic", "score": 82.1, "…": "…" } ],
"pillars": [ { "name": "Fiscal Capacity", "score": 80.0, "…": "…" } ]
}
```
Key fields for agents:
- `overallScore` (0100): headline number.
- `level`: `LOW` / `MODERATE` / `HIGH` / `VERY_HIGH` — human-readable bucket.
- `change30d`: rolling 30-day delta.
- `scoreInterval`: `{lower, upper}` confidence band — quote this when the user asks for precision.
- `domains` / `pillars`: drill-down components if the user asks "why".
## Worked example
```bash
curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \
'https://api.worldmonitor.app/api/resilience/v1/get-resilience-score?countryCode=DE' \
| jq '{country: .countryCode, score: .overallScore, level, trend, change30d}'
```
## Errors
- `400``countryCode` missing or malformed.
- `401` — missing `X-WorldMonitor-Key`.
- `403` — key present but not attached to a Pro-tier subscription.
- `404` — country not yet scored (rare; some micro-states).
- `429` — per-key rate limit hit.
## When NOT to use
- For a sorted list across all countries, call `GetResilienceRanking` (`/api/resilience/v1/get-resilience-ranking`) instead of N per-country calls.
- For a narrative summary rather than a number, use `fetch-country-brief`.
## References
- OpenAPI: [ResilienceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetResilienceScore`.
- Auth matrix: https://www.worldmonitor.app/docs/usage-auth
- Methodology: https://www.worldmonitor.app/docs/documentation

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://agentskills.io/schemas/v0.2.0/index.json",
"skills": [
{
"name": "fetch-country-brief",
"type": "task",
"description": "Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code.",
"url": "https://worldmonitor.app/.well-known/agent-skills/fetch-country-brief/SKILL.md",
"sha256": "82a76d25398856adaf72b5977d58585259caae7ce8cad24f5c2603fb2a3c76e0"
},
{
"name": "fetch-resilience-score",
"type": "task",
"description": "Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country.",
"url": "https://worldmonitor.app/.well-known/agent-skills/fetch-resilience-score/SKILL.md",
"sha256": "693cc336e0f5212de6ee462be69e6d987d652320f7770e1db2316800e31f21c0"
}
]
}

View File

@@ -29,6 +29,15 @@
"type": "application/json"
}
]
},
{
"anchor": "https://worldmonitor.app/",
"agent-skills-index": [
{
"href": "https://worldmonitor.app/.well-known/agent-skills/index.json",
"type": "application/json"
}
]
}
]
}

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env node
// Emits public/.well-known/agent-skills/index.json per the Agent Skills
// Discovery RFC v0.2.0. Each entry points at a SKILL.md and carries a
// sha256 of that file's exact served bytes, so agents can verify the
// skill text hasn't changed since they last fetched it.
//
// Source of truth: public/.well-known/agent-skills/<name>/SKILL.md
// Output: public/.well-known/agent-skills/index.json
//
// Run locally via `npm run build:agent-skills`. CI re-runs this and
// diffs the output against the committed index.json to block drift.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const __filename = fileURLToPath(import.meta.url);
const ROOT = resolve(dirname(__filename), '..');
const SKILLS_DIR = resolve(ROOT, 'public/.well-known/agent-skills');
const INDEX_PATH = join(SKILLS_DIR, 'index.json');
const PUBLIC_BASE = 'https://worldmonitor.app';
const SCHEMA = 'https://agentskills.io/schemas/v0.2.0/index.json';
// Closing fence must be anchored to its own line so values that happen to
// start with `---` in the body can't prematurely terminate frontmatter.
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
function sha256Hex(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
export function parseFrontmatter(md) {
const match = FRONTMATTER_RE.exec(md);
if (!match) return {};
const parsed = yaml.load(match[1]);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Frontmatter must be a YAML mapping');
}
return parsed;
}
function collectSkills() {
const entries = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
return entries.map((name) => {
const skillPath = join(SKILLS_DIR, name, 'SKILL.md');
const stat = statSync(skillPath);
if (!stat.isFile()) {
throw new Error(`Expected ${skillPath} to exist and be a file`);
}
const bytes = readFileSync(skillPath);
const md = bytes.toString('utf-8');
const fm = parseFrontmatter(md);
if (!fm.description) {
throw new Error(`${skillPath} missing "description" in frontmatter`);
}
if (fm.name && fm.name !== name) {
throw new Error(
`${skillPath} frontmatter name="${fm.name}" disagrees with directory "${name}"`,
);
}
return {
name,
type: 'task',
description: fm.description,
url: `${PUBLIC_BASE}/.well-known/agent-skills/${name}/SKILL.md`,
sha256: sha256Hex(bytes),
};
});
}
function build() {
const skills = collectSkills();
if (skills.length === 0) {
throw new Error(`No skills found under ${SKILLS_DIR}`);
}
const index = { $schema: SCHEMA, skills };
return JSON.stringify(index, null, 2) + '\n';
}
function main() {
const content = build();
const check = process.argv.includes('--check');
if (check) {
const current = readFileSync(INDEX_PATH, 'utf-8');
if (current !== content) {
process.stderr.write(
'agent-skills index.json is out of date. Run `npm run build:agent-skills`.\n',
);
process.exit(1);
}
process.stdout.write('agent-skills index.json is up to date.\n');
return;
}
writeFileSync(INDEX_PATH, content);
process.stdout.write(`Wrote ${INDEX_PATH}\n`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,115 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatter } from '../scripts/build-agent-skills-index.mjs';
const __filename = fileURLToPath(import.meta.url);
const ROOT = resolve(dirname(__filename), '..');
const INDEX_PATH = join(ROOT, 'public/.well-known/agent-skills/index.json');
const SKILLS_DIR = join(ROOT, 'public/.well-known/agent-skills');
// Guards for the Agent Skills discovery manifest (#3310 / epic #3306).
// Agents trust the index.json sha256 fields; if they drift from the
// served SKILL.md bytes, every downstream verification check fails.
describe('agent readiness: agent-skills index', () => {
it('index.json is up to date relative to SKILL.md sources', () => {
// `--check` exits non-zero if rebuilding the index would change it.
execFileSync(
process.execPath,
['scripts/build-agent-skills-index.mjs', '--check'],
{ cwd: ROOT, stdio: 'pipe' },
);
});
const index = JSON.parse(readFileSync(INDEX_PATH, 'utf-8'));
it('declares the RFC v0.2.0 schema', () => {
assert.equal(index.$schema, 'https://agentskills.io/schemas/v0.2.0/index.json');
});
it('advertises at least two skills (epic #3306 acceptance floor)', () => {
assert.ok(Array.isArray(index.skills));
assert.ok(index.skills.length >= 2, `expected >=2 skills, got ${index.skills.length}`);
});
it('every entry points at a real SKILL.md whose bytes match the declared sha256', () => {
for (const skill of index.skills) {
assert.ok(skill.name, 'skill entry missing name');
assert.equal(skill.type, 'task');
assert.ok(skill.description && skill.description.length > 0, `${skill.name} missing description`);
assert.match(
skill.url,
/^https:\/\/worldmonitor\.app\/\.well-known\/agent-skills\/[^/]+\/SKILL\.md$/,
`${skill.name} url must be the canonical absolute URL`,
);
const local = join(SKILLS_DIR, skill.name, 'SKILL.md');
const bytes = readFileSync(local);
const hex = createHash('sha256').update(bytes).digest('hex');
assert.equal(
skill.sha256,
hex,
`${skill.name} sha256 does not match ${local}`,
);
}
});
it('every SKILL.md directory is represented in the index (no orphans)', () => {
const dirs = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
const names = index.skills.map((s) => s.name).sort();
assert.deepEqual(names, dirs, 'every skill directory must have an index entry');
});
});
// Parser-contract tests for parseFrontmatter(). The previous hand-rolled
// parser matched `\n---` anywhere, so a body line beginning with `---`
// silently truncated the frontmatter. It also split on the first colon
// without YAML semantics, so quoted-colon values became brittle. Lock in
// the replacement's semantics so future edits don't regress either.
describe('agent-skills index: frontmatter parser', () => {
it('closing fence must be on its own line (body `---` does not terminate)', () => {
const md = [
'---',
'name: demo',
'description: covers body that starts with three dashes',
'---',
'',
'--- this dash line is body text, not a fence ---',
'More body.',
].join('\n');
const fm = parseFrontmatter(md);
assert.equal(fm.name, 'demo');
assert.equal(fm.description, 'covers body that starts with three dashes');
});
it('values containing colons are preserved (not truncated)', () => {
const md = [
'---',
'name: demo',
'description: "Retrieve X: the composite value at a point in time"',
'---',
'',
'body',
].join('\n');
const fm = parseFrontmatter(md);
assert.equal(
fm.description,
'Retrieve X: the composite value at a point in time',
);
});
it('rejects non-mapping frontmatter (e.g. a YAML list)', () => {
const md = ['---', '- a', '- b', '---', '', 'body'].join('\n');
assert.throws(() => parseFrontmatter(md), /YAML mapping/);
});
it('returns empty object when no frontmatter present', () => {
assert.deepEqual(parseFrontmatter('# Just a markdown heading\n'), {});
});
});

View File

@@ -509,6 +509,7 @@ describe('agent readiness: homepage Link headers', () => {
'rel="http://www.iana.org/assignments/relation/oauth-protected-resource"',
'rel="http://www.iana.org/assignments/relation/oauth-authorization-server"',
'rel="mcp-server-card"',
'rel="agent-skills-index"',
];
for (const rel of requiredRels) {
assert.ok(

View File

@@ -112,14 +112,14 @@
"source": "/",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" },
{ "key": "Link", "value": "</.well-known/api-catalog>; rel=\"api-catalog\"; type=\"application/linkset+json\", </openapi.yaml>; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", </docs/documentation>; rel=\"service-doc\"; type=\"text/html\", </api/health>; rel=\"status\"; type=\"application/json\", </.well-known/oauth-protected-resource>; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", </.well-known/oauth-authorization-server>; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", </.well-known/mcp/server-card.json>; rel=\"mcp-server-card\"; anchor=\"/mcp\"" }
{ "key": "Link", "value": "</.well-known/api-catalog>; rel=\"api-catalog\"; type=\"application/linkset+json\", </openapi.yaml>; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", </docs/documentation>; rel=\"service-doc\"; type=\"text/html\", </api/health>; rel=\"status\"; type=\"application/json\", </.well-known/oauth-protected-resource>; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", </.well-known/oauth-authorization-server>; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", </.well-known/mcp/server-card.json>; rel=\"mcp-server-card\"; anchor=\"/mcp\", </.well-known/agent-skills/index.json>; rel=\"agent-skills-index\"; type=\"application/json\"" }
]
},
{
"source": "/index.html",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" },
{ "key": "Link", "value": "</.well-known/api-catalog>; rel=\"api-catalog\"; type=\"application/linkset+json\", </openapi.yaml>; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", </docs/documentation>; rel=\"service-doc\"; type=\"text/html\", </api/health>; rel=\"status\"; type=\"application/json\", </.well-known/oauth-protected-resource>; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", </.well-known/oauth-authorization-server>; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", </.well-known/mcp/server-card.json>; rel=\"mcp-server-card\"; anchor=\"/mcp\"" }
{ "key": "Link", "value": "</.well-known/api-catalog>; rel=\"api-catalog\"; type=\"application/linkset+json\", </openapi.yaml>; rel=\"service-desc\"; type=\"application/vnd.oai.openapi\", </docs/documentation>; rel=\"service-doc\"; type=\"text/html\", </api/health>; rel=\"status\"; type=\"application/json\", </.well-known/oauth-protected-resource>; rel=\"http://www.iana.org/assignments/relation/oauth-protected-resource\", </.well-known/oauth-authorization-server>; rel=\"http://www.iana.org/assignments/relation/oauth-authorization-server\", </.well-known/mcp/server-card.json>; rel=\"mcp-server-card\"; anchor=\"/mcp\", </.well-known/agent-skills/index.json>; rel=\"agent-skills-index\"; type=\"application/json\"" }
]
},
{