Skip to content

Commit def9473

Browse files
authored
feat(agent-readiness): Agent Skills discovery index (koala73#3310) (koala73#3355)
* feat(agent-readiness): Agent Skills discovery index (koala73#3310) Closes koala73#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 koala73#3322 fixed the apex redirect that previously hid .well-known paths. * fix(agent-readiness): correct auth header + harden frontmatter parser (koala73#3310) Addresses review findings on koala73#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.
1 parent 7cf0c32 commit def9473

9 files changed

Lines changed: 427 additions & 8 deletions

File tree

package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,16 @@
2424
"build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/",
2525
"build:pro": "cd pro-test && npm install && npm run build",
2626
"build:openapi": "node -e \"require('fs').cpSync('docs/api/worldmonitor.openapi.yaml', 'public/openapi.yaml')\"",
27-
"prebuild": "npm run build:openapi",
27+
"build:agent-skills": "node scripts/build-agent-skills-index.mjs",
28+
"prebuild": "npm run build:openapi && npm run build:agent-skills",
2829
"build": "npm run build:blog && tsc && vite build",
2930
"build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs",
3031
"build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build",
31-
"build:full": "npm run build:openapi && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
32-
"build:tech": "npm run build:openapi && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
33-
"build:finance": "npm run build:openapi && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
34-
"build:happy": "npm run build:openapi && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
35-
"build:commodity": "npm run build:openapi && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
32+
"build:full": "npm run build:openapi && npm run build:agent-skills && npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
33+
"build:tech": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
34+
"build:finance": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
35+
"build:happy": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
36+
"build:commodity": "npm run build:openapi && npm run build:agent-skills && cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
3637
"typecheck": "tsc --noEmit",
3738
"typecheck:api": "tsc --noEmit -p tsconfig.api.json",
3839
"typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
name: fetch-country-brief
3+
version: 1
4+
description: Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code.
5+
---
6+
7+
# fetch-country-brief
8+
9+
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.
10+
11+
## Authentication
12+
13+
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.
14+
15+
```
16+
X-WorldMonitor-Key: wm_live_...
17+
```
18+
19+
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.
20+
21+
## Endpoint
22+
23+
```
24+
GET https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief
25+
```
26+
27+
## Parameters
28+
29+
| Name | In | Required | Shape | Notes |
30+
|---|---|---|---|---|
31+
| `country_code` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `US`, `IR`, `KE`) | Case-sensitive server-side. Lowercase is rejected with 400. |
32+
| `framework` | query | no | free text, ≤ 2000 chars | Optional analytical framing appended to the system prompt (e.g. `"focus on energy security"`). |
33+
34+
## Response shape
35+
36+
```json
37+
{
38+
"countryCode": "IR",
39+
"countryName": "Iran",
40+
"brief": "Multi-paragraph AI-generated brief …",
41+
"model": "gpt-4o-mini",
42+
"generatedAt": 1745421600000
43+
}
44+
```
45+
46+
`generatedAt` is Unix epoch milliseconds. `model` identifies which LLM produced the text.
47+
48+
## Worked example
49+
50+
```bash
51+
curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \
52+
'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief?country_code=IR' \
53+
| jq -r '.brief'
54+
```
55+
56+
With an analytical framework:
57+
58+
```bash
59+
curl -s --get -H "X-WorldMonitor-Key: $WM_API_KEY" \
60+
'https://api.worldmonitor.app/api/intelligence/v1/get-country-intel-brief' \
61+
--data-urlencode 'country_code=TR' \
62+
--data-urlencode 'framework=focus on energy corridors and Black Sea shipping'
63+
```
64+
65+
## Errors
66+
67+
- `400``country_code` missing, not 2 letters, or not uppercase.
68+
- `401` — missing `X-WorldMonitor-Key` (server-to-server callers).
69+
- `429` — rate limited; retry with backoff.
70+
- `5xx` — transient upstream model failure; retry once after 2s.
71+
72+
## When NOT to use
73+
74+
- For rankings or comparisons across countries, use `fetch-resilience-score` per country and aggregate client-side, or call the `GetResilienceRanking` RPC directly.
75+
- For raw news events rather than synthesized narrative, use `SearchGdeltDocuments` (`/api/intelligence/v1/search-gdelt-documents`).
76+
77+
## References
78+
79+
- OpenAPI: [IntelligenceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetCountryIntelBrief`.
80+
- Auth matrix: https://www.worldmonitor.app/docs/usage-auth
81+
- Documentation: https://www.worldmonitor.app/docs/documentation
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
name: fetch-resilience-score
3+
version: 1
4+
description: Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country.
5+
---
6+
7+
# fetch-resilience-score
8+
9+
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.
10+
11+
## Authentication — required
12+
13+
`/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.
14+
15+
```
16+
X-WorldMonitor-Key: wm_live_...
17+
```
18+
19+
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.
20+
21+
## Endpoint
22+
23+
```
24+
GET https://api.worldmonitor.app/api/resilience/v1/get-resilience-score
25+
```
26+
27+
## Parameters
28+
29+
| Name | In | Required | Shape |
30+
|---|---|---|---|
31+
| `countryCode` | query | yes | ISO 3166-1 alpha-2, uppercase (e.g. `DE`, `KE`, `BR`) |
32+
33+
## Response shape
34+
35+
```json
36+
{
37+
"countryCode": "DE",
38+
"overallScore": 78.4,
39+
"level": "HIGH",
40+
"trend": "STABLE",
41+
"change30d": -0.2,
42+
"lowConfidence": false,
43+
"imputationShare": 0.04,
44+
"baselineScore": 79.1,
45+
"stressScore": 78.4,
46+
"stressFactor": 0.99,
47+
"dataVersion": "2026-04-23",
48+
"scoreInterval": { "lower": 76.1, "upper": 80.7 },
49+
"domains": [ { "name": "Economic", "score": 82.1, "…": "" } ],
50+
"pillars": [ { "name": "Fiscal Capacity", "score": 80.0, "…": "" } ]
51+
}
52+
```
53+
54+
Key fields for agents:
55+
56+
- `overallScore` (0–100): headline number.
57+
- `level`: `LOW` / `MODERATE` / `HIGH` / `VERY_HIGH` — human-readable bucket.
58+
- `change30d`: rolling 30-day delta.
59+
- `scoreInterval`: `{lower, upper}` confidence band — quote this when the user asks for precision.
60+
- `domains` / `pillars`: drill-down components if the user asks "why".
61+
62+
## Worked example
63+
64+
```bash
65+
curl -s -H "X-WorldMonitor-Key: $WM_API_KEY" \
66+
'https://api.worldmonitor.app/api/resilience/v1/get-resilience-score?countryCode=DE' \
67+
| jq '{country: .countryCode, score: .overallScore, level, trend, change30d}'
68+
```
69+
70+
## Errors
71+
72+
- `400``countryCode` missing or malformed.
73+
- `401` — missing `X-WorldMonitor-Key`.
74+
- `403` — key present but not attached to a Pro-tier subscription.
75+
- `404` — country not yet scored (rare; some micro-states).
76+
- `429` — per-key rate limit hit.
77+
78+
## When NOT to use
79+
80+
- For a sorted list across all countries, call `GetResilienceRanking` (`/api/resilience/v1/get-resilience-ranking`) instead of N per-country calls.
81+
- For a narrative summary rather than a number, use `fetch-country-brief`.
82+
83+
## References
84+
85+
- OpenAPI: [ResilienceService.openapi.yaml](https://www.worldmonitor.app/openapi.yaml) — operation `GetResilienceScore`.
86+
- Auth matrix: https://www.worldmonitor.app/docs/usage-auth
87+
- Methodology: https://www.worldmonitor.app/docs/documentation
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "https://agentskills.io/schemas/v0.2.0/index.json",
3+
"skills": [
4+
{
5+
"name": "fetch-country-brief",
6+
"type": "task",
7+
"description": "Retrieve the current AI-generated strategic intelligence brief for a country, keyed by ISO 3166-1 alpha-2 code.",
8+
"url": "https://worldmonitor.app/.well-known/agent-skills/fetch-country-brief/SKILL.md",
9+
"sha256": "82a76d25398856adaf72b5977d58585259caae7ce8cad24f5c2603fb2a3c76e0"
10+
},
11+
{
12+
"name": "fetch-resilience-score",
13+
"type": "task",
14+
"description": "Retrieve the composite country resilience score (0-100) and its domain/pillar breakdown for a single country.",
15+
"url": "https://worldmonitor.app/.well-known/agent-skills/fetch-resilience-score/SKILL.md",
16+
"sha256": "693cc336e0f5212de6ee462be69e6d987d652320f7770e1db2316800e31f21c0"
17+
}
18+
]
19+
}

public/.well-known/api-catalog

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
2929
"type": "application/json"
3030
}
3131
]
32+
},
33+
{
34+
"anchor": "https://worldmonitor.app/",
35+
"agent-skills-index": [
36+
{
37+
"href": "https://worldmonitor.app/.well-known/agent-skills/index.json",
38+
"type": "application/json"
39+
}
40+
]
3241
}
3342
]
3443
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env node
2+
// Emits public/.well-known/agent-skills/index.json per the Agent Skills
3+
// Discovery RFC v0.2.0. Each entry points at a SKILL.md and carries a
4+
// sha256 of that file's exact served bytes, so agents can verify the
5+
// skill text hasn't changed since they last fetched it.
6+
//
7+
// Source of truth: public/.well-known/agent-skills/<name>/SKILL.md
8+
// Output: public/.well-known/agent-skills/index.json
9+
//
10+
// Run locally via `npm run build:agent-skills`. CI re-runs this and
11+
// diffs the output against the committed index.json to block drift.
12+
13+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
14+
import { createHash } from 'node:crypto';
15+
import { resolve, dirname, join } from 'node:path';
16+
import { fileURLToPath } from 'node:url';
17+
import yaml from 'js-yaml';
18+
19+
const __filename = fileURLToPath(import.meta.url);
20+
const ROOT = resolve(dirname(__filename), '..');
21+
const SKILLS_DIR = resolve(ROOT, 'public/.well-known/agent-skills');
22+
const INDEX_PATH = join(SKILLS_DIR, 'index.json');
23+
const PUBLIC_BASE = 'https://worldmonitor.app';
24+
25+
const SCHEMA = 'https://agentskills.io/schemas/v0.2.0/index.json';
26+
// Closing fence must be anchored to its own line so values that happen to
27+
// start with `---` in the body can't prematurely terminate frontmatter.
28+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
29+
30+
function sha256Hex(bytes) {
31+
return createHash('sha256').update(bytes).digest('hex');
32+
}
33+
34+
export function parseFrontmatter(md) {
35+
const match = FRONTMATTER_RE.exec(md);
36+
if (!match) return {};
37+
const parsed = yaml.load(match[1]);
38+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39+
throw new Error('Frontmatter must be a YAML mapping');
40+
}
41+
return parsed;
42+
}
43+
44+
function collectSkills() {
45+
const entries = readdirSync(SKILLS_DIR, { withFileTypes: true })
46+
.filter((d) => d.isDirectory())
47+
.map((d) => d.name)
48+
.sort();
49+
50+
return entries.map((name) => {
51+
const skillPath = join(SKILLS_DIR, name, 'SKILL.md');
52+
const stat = statSync(skillPath);
53+
if (!stat.isFile()) {
54+
throw new Error(`Expected ${skillPath} to exist and be a file`);
55+
}
56+
const bytes = readFileSync(skillPath);
57+
const md = bytes.toString('utf-8');
58+
const fm = parseFrontmatter(md);
59+
if (!fm.description) {
60+
throw new Error(`${skillPath} missing "description" in frontmatter`);
61+
}
62+
if (fm.name && fm.name !== name) {
63+
throw new Error(
64+
`${skillPath} frontmatter name="${fm.name}" disagrees with directory "${name}"`,
65+
);
66+
}
67+
return {
68+
name,
69+
type: 'task',
70+
description: fm.description,
71+
url: `${PUBLIC_BASE}/.well-known/agent-skills/${name}/SKILL.md`,
72+
sha256: sha256Hex(bytes),
73+
};
74+
});
75+
}
76+
77+
function build() {
78+
const skills = collectSkills();
79+
if (skills.length === 0) {
80+
throw new Error(`No skills found under ${SKILLS_DIR}`);
81+
}
82+
const index = { $schema: SCHEMA, skills };
83+
return JSON.stringify(index, null, 2) + '\n';
84+
}
85+
86+
function main() {
87+
const content = build();
88+
const check = process.argv.includes('--check');
89+
if (check) {
90+
const current = readFileSync(INDEX_PATH, 'utf-8');
91+
if (current !== content) {
92+
process.stderr.write(
93+
'agent-skills index.json is out of date. Run `npm run build:agent-skills`.\n',
94+
);
95+
process.exit(1);
96+
}
97+
process.stdout.write('agent-skills index.json is up to date.\n');
98+
return;
99+
}
100+
writeFileSync(INDEX_PATH, content);
101+
process.stdout.write(`Wrote ${INDEX_PATH}\n`);
102+
}
103+
104+
if (import.meta.url === `file://${process.argv[1]}`) {
105+
main();
106+
}

0 commit comments

Comments
 (0)