prop/lib/microwaveprop_web/controllers/agent_skills_controller.ex
Graham McIntire fc9d0dc977
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.
2026-04-17 12:09:05 -05:00

72 lines
2.3 KiB
Elixir

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