feat(agent-discovery): Link/markdown/content-signal/api-catalog/skills

Implements the subset of the isitagentready.com checklist that maps to
real capabilities of this site:

- RFC 8288 Link headers on every browser response advertising
  service-doc (/algo), about, privacy-policy, and sitemap.
- Markdown-for-Agents content negotiation: requests with
  Accept: text/markdown for / or /algo return real markdown
  (curated summary + verbatim algo.md) with x-markdown-tokens hint.
  Other paths still 406 — no synthesizing markdown from LiveView HTML.
- Content-Signal directive in robots.txt declaring
  search=yes, ai-train=no, ai-input=no.
- RFC 9727 API catalog at /.well-known/api-catalog with the sole
  public endpoint (/api/contacts/map) linking status -> /health and
  service-desc -> /openapi.json (a new minimal OpenAPI 3.0 spec).
- Agent Skills Discovery index at /.well-known/agent-skills/index.json
  listing two skill documents (fetch-contacts, read-algorithm) with
  sha256 digests computed at compile time; documents served at
  /.well-known/agent-skills/{name}/SKILL.md.

Skipped (don't match actual site capabilities): Web Bot Auth JWKS,
OAuth/OIDC discovery, MCP server card.
This commit is contained in:
Graham McIntire 2026-04-17 12:09:05 -05:00
parent 6972feb2c2
commit fc9d0dc977
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
9 changed files with 489 additions and 0 deletions

View file

@ -0,0 +1,72 @@
defmodule MicrowavepropWeb.AgentSkillsController do
@moduledoc """
Publishes an Agent Skills Discovery (v0.2.0) index at
`/.well-known/agent-skills/index.json` plus the referenced skill
documents. Each document is a short markdown file describing one
concrete task an agent can perform against this service's public
endpoints. Digests are computed at compile time from the source files.
"""
use MicrowavepropWeb, :controller
@schema_url "https://agentskills.io/schemas/v0.2.0/skills.schema.json"
@skill_sources [
%{
name: "fetch-contacts",
description:
"Retrieve every amateur-radio contact in the propagation calibration dataset as a JSON array of compact tuples.",
path: "priv/agent-skills/fetch-contacts.md"
},
%{
name: "read-algorithm",
description:
"Obtain the full NTMS propagation scoring algorithm documentation, including factor weights and ITU-R model references, as markdown.",
path: "priv/agent-skills/read-algorithm.md"
}
]
for %{path: path} <- @skill_sources do
@external_resource path
end
@skills Enum.map(@skill_sources, fn %{name: name, description: desc, path: path} ->
body = File.read!(path)
sha = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower)
%{name: name, description: desc, body: body, sha256: sha}
end)
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, _params) do
entries =
Enum.map(@skills, fn s ->
%{
name: s.name,
type: "text/markdown",
description: s.description,
url: url(conn, ~p"/.well-known/agent-skills/#{s.name}/SKILL.md"),
sha256: s.sha256
}
end)
body = Jason.encode!(%{"$schema" => @schema_url, skills: entries})
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(200, body)
end
@spec skill(Plug.Conn.t(), map()) :: Plug.Conn.t()
def skill(conn, %{"name" => name}) do
case Enum.find(@skills, &(&1.name == name)) do
nil ->
send_resp(conn, 404, "")
s ->
conn
|> put_resp_content_type("text/markdown", "utf-8")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(200, s.body)
end
end
end

View file

@ -0,0 +1,83 @@
defmodule MicrowavepropWeb.ApiCatalogController do
@moduledoc """
RFC 9727 API catalog + companion OpenAPI spec for automated API discovery.
`/api/contacts/map` is currently the only public JSON endpoint. The
catalog lists it with link relations for `service-desc` (OpenAPI) and
`status` (health check). If more endpoints are added, extend
`linkset_entries/1` and the OpenAPI `paths` map.
"""
use MicrowavepropWeb, :controller
@openapi_type "application/vnd.oai.openapi+json;version=3.0"
@spec catalog(Plug.Conn.t(), map()) :: Plug.Conn.t()
def catalog(conn, _params) do
body = Jason.encode!(%{linkset: linkset_entries(conn)})
conn
|> put_resp_content_type("application/linkset+json")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(200, body)
end
@spec openapi(Plug.Conn.t(), map()) :: Plug.Conn.t()
def openapi(conn, _params) do
body = Jason.encode!(openapi_spec(conn))
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(200, body)
end
defp linkset_entries(conn) do
[
%{
anchor: url(conn, ~p"/api/contacts/map"),
"service-desc": [%{href: url(conn, ~p"/openapi.json"), type: @openapi_type}],
status: [%{href: url(conn, ~p"/health")}]
}
]
end
defp openapi_spec(conn) do
%{
openapi: "3.0.3",
info: %{
title: "Microwaveprop Public API",
description: "Public JSON endpoints for the NTMS microwave propagation prediction service.",
version: "1.0.0"
},
servers: [%{url: url(conn, ~p"/")}],
paths: %{
"/api/contacts/map" => %{
get: %{
summary: "All recorded amateur-radio contacts as compact tuples",
description:
"Returns every contact in the calibration dataset as an array of fixed-shape tuples. " <>
"Response is pre-gzipped when the client sends Accept-Encoding: gzip. " <>
"Tuple shape: [lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp, id].",
responses: %{
"200" => %{
description: "Array of contact tuples",
content: %{
"application/json" => %{
schema: %{
type: "array",
items: %{
type: "array",
minItems: 11,
maxItems: 11
}
}
}
}
}
}
}
}
}
}
end
end

View file

@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Router do
import Phoenix.LiveDashboard.Router
pipeline :browser do
plug :serve_markdown_if_requested
plug :accepts, ["html"]
plug :fetch_session
plug :store_remote_ip
@ -14,9 +15,97 @@ defmodule MicrowavepropWeb.Router do
plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_agent_link_headers
plug :fetch_current_scope_for_user
end
# RFC 8288 Link headers advertising resources for agent/crawler discovery.
# `/algo` is the human-readable algorithm/service documentation, rendered
# from `algo.md`. `/sitemap.xml` is the registered "sitemap" rel. About
# and privacy use their IANA-registered rels.
@agent_link_header Enum.join(
[
~s(</algo>; rel="service-doc"),
~s(</about>; rel="about"),
~s(</privacy>; rel="privacy-policy"),
~s(</sitemap.xml>; rel="sitemap")
],
", "
)
defp put_agent_link_headers(conn, _opts) do
Plug.Conn.put_resp_header(conn, "link", @agent_link_header)
end
# Markdown for Agents: if the client prefers `text/markdown`, serve real
# markdown for the paths where we have markdown source. Other paths fall
# through to the `:accepts` plug and 406 — we don't synthesize markdown
# from LiveView HTML on the fly, since a lossy conversion would mislead
# agents about site content.
@external_resource "algo.md"
@algo_markdown File.read!("algo.md")
@homepage_markdown """
# NTMS Propagation Prediction
The North Texas Microwave Society's propagation prediction service for
amateur radio bands 10241 GHz. Scores propagation conditions across
CONUS using HRRR numerical weather prediction, sounding data, ITU-R
atmospheric models, and calibration against 57,000+ recorded QSOs.
## Primary Resources
- [Propagation map](/map) real-time CONUS propagation scores + 18-hour forecast.
- [Algorithm documentation](/algo) full methodology, factor weights, ITU-R references (also available as `text/markdown`).
- [Contact database](/contacts) 58,000+ historical QSOs used for calibration.
- [Contact map](/contacts/map) geographic view of all recorded contacts.
- [Path calculator](/path) point-to-point propagation prediction.
- [Beacons](/beacons) microwave beacon directory.
- [Submit a contact](/submit) add a QSO to the calibration dataset.
## Meta
- [About](/about)
- [Privacy](/privacy)
- [Sitemap](/sitemap.xml)
## API
- `GET /api/contacts/map` JSON tuples of every contact on the map (lat/lon/band/callsigns/mode/distance/timestamp/id). Pre-gzipped, ~5 MB decompressed.
"""
defp serve_markdown_if_requested(conn, _opts) do
if wants_markdown?(conn) do
case markdown_for_path(conn.request_path) do
nil ->
conn
body ->
conn
|> Plug.Conn.put_resp_content_type("text/markdown", "utf-8")
|> Plug.Conn.put_resp_header("x-markdown-tokens", estimate_tokens(body))
|> Plug.Conn.send_resp(200, body)
|> Plug.Conn.halt()
end
else
conn
end
end
defp wants_markdown?(conn) do
conn
|> Plug.Conn.get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "text/markdown"))
end
defp markdown_for_path("/"), do: @homepage_markdown
defp markdown_for_path("/algo"), do: @algo_markdown
defp markdown_for_path(_), do: nil
# Rough token estimate: ~4 chars per token is the widely-used OpenAI
# heuristic. Good enough for the `x-markdown-tokens` hint.
defp estimate_tokens(body), do: body |> byte_size() |> div(4) |> Integer.to_string()
defp store_remote_ip(conn, _opts) do
ip_str = conn.remote_ip |> :inet.ntoa() |> to_string()
Plug.Conn.put_session(conn, :remote_ip, ip_str)
@ -46,6 +135,18 @@ defmodule MicrowavepropWeb.Router do
# Health check — no pipeline, minimal overhead
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline,
# no session — agents and crawlers fetch them without auth.
get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog
get "/openapi.json", MicrowavepropWeb.ApiCatalogController, :openapi
# Agent Skills Discovery (v0.2.0). Index + individual skill documents.
get "/.well-known/agent-skills/index.json", MicrowavepropWeb.AgentSkillsController, :index
get "/.well-known/agent-skills/:name/SKILL.md",
MicrowavepropWeb.AgentSkillsController,
:skill
# Admin-only routes must come first so that literal segments like
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
scope "/", MicrowavepropWeb do

View file

@ -0,0 +1,32 @@
# Fetch contacts dataset
The NTMS microwave propagation service publishes its full amateur-radio contact (QSO) calibration dataset as a single public JSON endpoint. No authentication is required.
## Endpoint
```
GET https://prop.w5isp.com/api/contacts/map
```
The response is pre-gzipped when the client sends `Accept-Encoding: gzip` (roughly 1 MB compressed, 5 MB decompressed). Cached at the origin for 60 s.
## Response shape
A single JSON array where each element is a fixed-length tuple:
```
[lat1, lon1, lat2, lon2, band_mhz, callsign1, callsign2, mode, distance_km, qso_timestamp_iso8601, id_uuid]
```
- `lat1/lon1` and `lat2/lon2` are the two station locations (WGS84 degrees).
- `band_mhz` is an integer in MHz (e.g. `1296`, `10000`, `24000`).
- `callsign1/callsign2` are uppercase amateur callsigns or `null` if unknown.
- `mode` is a string (`CW`, `SSB`, `FT8`, …) or `null`.
- `distance_km` is a number or `null`.
- `qso_timestamp_iso8601` is `YYYY-MM-DDTHH:MM:SSZ`.
- `id_uuid` is the stable UUID for the contact; fetch details at `/contacts/{id_uuid}`.
## Machine-readable specs
- OpenAPI 3.0 spec: `GET /openapi.json`
- API catalog (RFC 9727): `GET /.well-known/api-catalog`

View file

@ -0,0 +1,27 @@
# Read propagation algorithm documentation
The scoring algorithm for amateur-radio microwave propagation (902 MHz 241 GHz) is documented in long-form markdown, covering factor weights, band-specific configurations, ITU-R atmospheric models, and calibration dataset composition.
## Endpoint
```
GET https://prop.w5isp.com/algo
```
The default response is HTML. Send `Accept: text/markdown` to receive the raw markdown source directly:
```
GET https://prop.w5isp.com/algo
Accept: text/markdown
```
Response: `Content-Type: text/markdown; charset=utf-8`. An `x-markdown-tokens` header gives a rough token-count estimate for context-window planning.
## Content summary
- Scoring methodology: 10 weighted factors combining to a 0100 composite score per grid point per band.
- Factor weights (humidity 13.6 %, time of day 5.0 %, T-Td depression 9.8 %, refractivity 10.5 %, sky 8.0 %, season 11.1 %, wind 8.0 %, rain 13.6 %, pressure 10.3 %, PWAT 11.3 %) recalibrated 2026-04-11 via gradient descent against 57,488 tropospheric QSOs.
- Band-dependent humidity effect: beneficial at 10 GHz (refractivity gradients), harmful at 24 GHz+ (gaseous absorption).
- ITU-R model references: P.838-3 (rain attenuation), P.526-16 (diffraction), P.676 (gaseous absorption).
- Terrain analysis: 97.2 % of all analysed QSO paths are terrain-blocked; atmospheric ducting and refraction are the primary enabling mechanisms.
- Calibration dataset: 58 k+ QSOs, HRRR atmospheric profiles, RAOB soundings, IEMRE gridded observations, SRTM terrain, and commercial link SNMP polling at 11/24/68 GHz.

View file

@ -4,5 +4,6 @@ Disallow: /backfill
Disallow: /dev/
Disallow: /dashboard
Disallow: /health
Content-Signal: search=yes, ai-train=no, ai-input=no
Sitemap: https://prop.w5isp.com/sitemap.xml

View file

@ -0,0 +1,60 @@
defmodule MicrowavepropWeb.AgentSkillsControllerTest do
use MicrowavepropWeb.ConnCase, async: true
describe "GET /.well-known/agent-skills/index.json" do
test "returns the agent-skills discovery index", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/index.json")
assert conn.status == 200
assert ["application/json" <> _] = get_resp_header(conn, "content-type")
body = Jason.decode!(conn.resp_body)
assert body["$schema"] =~ "agentskills"
assert is_list(body["skills"])
assert body["skills"] != []
for skill <- body["skills"] do
assert is_binary(skill["name"])
assert is_binary(skill["type"])
assert is_binary(skill["description"])
assert is_binary(skill["url"])
assert String.match?(skill["sha256"], ~r/^[0-9a-f]{64}$/)
end
end
test "index lists at least fetch-contacts and read-algorithm", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/index.json")
%{"skills" => skills} = Jason.decode!(conn.resp_body)
names = Enum.map(skills, & &1["name"])
assert "fetch-contacts" in names
assert "read-algorithm" in names
end
end
describe "GET /.well-known/agent-skills/:name/SKILL.md" do
test "serves a skill document as markdown", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/fetch-contacts/SKILL.md")
assert conn.status == 200
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
assert conn.resp_body =~ "Fetch contacts dataset"
end
test "sha256 in the index matches the served document bytes", %{conn: conn} do
%{"skills" => skills} =
conn |> get("/.well-known/agent-skills/index.json") |> Map.get(:resp_body) |> Jason.decode!()
for %{"name" => name, "sha256" => expected} <- skills do
doc = get(conn, "/.well-known/agent-skills/#{name}/SKILL.md").resp_body
actual = :sha256 |> :crypto.hash(doc) |> Base.encode16(case: :lower)
assert actual == expected, "sha256 mismatch for skill #{name}"
end
end
test "returns 404 for unknown skill names", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/does-not-exist/SKILL.md")
assert conn.status == 404
end
end
end

View file

@ -0,0 +1,46 @@
defmodule MicrowavepropWeb.ApiCatalogControllerTest do
use MicrowavepropWeb.ConnCase, async: true
describe "GET /.well-known/api-catalog (RFC 9727)" do
test "returns application/linkset+json with a linkset array", %{conn: conn} do
conn = get(conn, "/.well-known/api-catalog")
assert conn.status == 200
assert ["application/linkset+json" <> _] = get_resp_header(conn, "content-type")
body = Jason.decode!(conn.resp_body)
assert is_list(body["linkset"])
assert body["linkset"] != []
end
test "first entry anchors the contacts map endpoint with status and service-desc", %{conn: conn} do
conn = get(conn, "/.well-known/api-catalog")
%{"linkset" => [entry | _]} = Jason.decode!(conn.resp_body)
assert entry["anchor"] =~ "/api/contacts/map"
assert [%{"href" => status_href}] = entry["status"]
assert status_href =~ "/health"
assert [%{"href" => desc_href, "type" => desc_type}] = entry["service-desc"]
assert desc_href =~ "/openapi.json"
assert desc_type == "application/vnd.oai.openapi+json;version=3.0"
end
end
describe "GET /openapi.json" do
test "returns a valid OpenAPI 3.x document describing the public API", %{conn: conn} do
conn = get(conn, "/openapi.json")
assert conn.status == 200
assert ["application/json" <> _] = get_resp_header(conn, "content-type")
spec = Jason.decode!(conn.resp_body)
assert spec["openapi"] =~ ~r/^3\./
assert is_map(spec["info"])
assert is_binary(spec["info"]["title"])
assert is_binary(spec["info"]["version"])
assert Map.has_key?(spec["paths"], "/api/contacts/map")
end
end
end

View file

@ -5,4 +5,71 @@ defmodule MicrowavepropWeb.PageControllerTest do
conn = get(conn, ~p"/")
assert redirected_to(conn) == "/map"
end
# RFC 8288 Link headers for agent discovery. These advertise resources
# that actually exist in this app to crawlers/agents that look at
# response headers before parsing HTML.
describe "Link headers (RFC 8288)" do
test "homepage response carries Link headers", %{conn: conn} do
conn = get(conn, ~p"/")
[link] = get_resp_header(conn, "link")
assert link =~ ~s(</algo>; rel="service-doc")
assert link =~ ~s(</about>; rel="about")
assert link =~ ~s(</privacy>; rel="privacy-policy")
assert link =~ ~s(</sitemap.xml>; rel="sitemap")
end
test "browser-pipeline pages carry the same Link headers", %{conn: conn} do
conn = get(conn, ~p"/about")
[link] = get_resp_header(conn, "link")
assert link =~ ~s(rel="service-doc")
assert link =~ ~s(rel="about")
end
end
# Markdown for Agents: content negotiation so agents that send
# `Accept: text/markdown` get a real markdown document instead of a 406.
# We only serve markdown for paths where we actually have markdown source
# (the homepage summary and /algo). Other paths still 406 rather than lie
# with a converted-on-the-fly body.
describe "Markdown for Agents" do
test "GET / with Accept: text/markdown returns a markdown document", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/")
assert conn.status == 200
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
[tokens] = get_resp_header(conn, "x-markdown-tokens")
assert String.to_integer(tokens) > 0
assert conn.resp_body =~ "# "
end
test "GET /algo with Accept: text/markdown returns algo.md", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/algo")
assert conn.status == 200
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
assert conn.resp_body =~ "Microwave Propagation Algorithm"
end
test "GET / without markdown Accept still redirects to /map", %{conn: conn} do
conn = get(conn, ~p"/")
assert redirected_to(conn) == "/map"
end
test "Accept lists with both html and markdown prefer markdown when specified", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown, text/html;q=0.9")
|> get(~p"/")
assert ["text/markdown; charset=utf-8"] = get_resp_header(conn, "content-type")
end
end
end