22 KiB
Agent Readiness Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make towerops.net pass all isitagentready.com checks by adding sitemap, well-known endpoints, Link headers, markdown negotiation, and WebMCP support.
Architecture: Add a WellKnownController for all /.well-known/* JSON/stub endpoints, a SitemapController for /sitemap.xml, patch PageController for Link headers + markdown content negotiation, and add WebMCP JS to app.js. All endpoints are unauthenticated.
Tech Stack: Phoenix controllers, Elixir, plain JSON (no extra libraries), standard XML generation, vanilla JS in app.js.
Task 1: Sitemap XML + robots.txt
Files:
- Create:
lib/towerops_web/controllers/sitemap_controller.ex - Modify:
lib/towerops_web/router.ex - Modify:
priv/static/robots.txt - Test:
test/towerops_web/controllers/sitemap_controller_test.exs
Step 1: Write the failing test
# test/towerops_web/controllers/sitemap_controller_test.exs
defmodule ToweropsWeb.SitemapControllerTest do
use ToweropsWeb.ConnCase, async: true
describe "GET /sitemap.xml" do
test "returns valid XML with public pages", %{conn: conn} do
conn = get(conn, "/sitemap.xml")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/xml; charset=utf-8"]
body = response(conn, 200)
assert body =~ ~r/<\?xml/
assert body =~ "<urlset"
assert body =~ "<loc>https://towerops.net/</loc>"
assert body =~ "<loc>https://towerops.net/privacy</loc>"
assert body =~ "<loc>https://towerops.net/terms</loc>"
end
end
end
Step 2: Run test to verify it fails
cd /Users/graham/dev/towerops/towerops-web
mix test test/towerops_web/controllers/sitemap_controller_test.exs
Expected: FAIL with no route/action error.
Step 3: Add the route to router.ex
In lib/towerops_web/router.ex, after the health check scope:
# Sitemap (no pipeline — public, no CSRF)
scope "/", ToweropsWeb do
get "/sitemap.xml", SitemapController, :index
end
Step 4: Create the controller
# lib/towerops_web/controllers/sitemap_controller.ex
defmodule ToweropsWeb.SitemapController do
@moduledoc false
use ToweropsWeb, :controller
@public_urls ["/", "/privacy", "/terms", "/docs/api", "/docs/graphql", "/help"]
def index(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
xml = build_sitemap(base_url)
conn
|> put_resp_content_type("text/xml")
|> send_resp(200, xml)
end
defp build_sitemap(base_url) do
urls =
@public_urls
|> Enum.map(fn path ->
"<url><loc>#{base_url}#{path}</loc></url>"
end)
|> Enum.join("\n ")
"""
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
"""
|> String.trim()
end
end
Step 5: Update robots.txt
Replace priv/static/robots.txt content:
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
User-agent: *
Allow: /
Sitemap: https://towerops.net/sitemap.xml
Step 6: Run tests to verify they pass
mix test test/towerops_web/controllers/sitemap_controller_test.exs
Expected: All tests PASS.
Step 7: Commit
git add lib/towerops_web/controllers/sitemap_controller.ex \
test/towerops_web/controllers/sitemap_controller_test.exs \
lib/towerops_web/router.ex \
priv/static/robots.txt
git commit -m "feat: add /sitemap.xml and update robots.txt for agent discovery"
Task 2: Well-Known JSON Endpoints
Serve all /.well-known/* discovery endpoints from a single controller.
Files:
- Create:
lib/towerops_web/controllers/well_known_controller.ex - Modify:
lib/towerops_web/router.ex - Test:
test/towerops_web/controllers/well_known_controller_test.exs
Step 1: Write the failing tests
# test/towerops_web/controllers/well_known_controller_test.exs
defmodule ToweropsWeb.WellKnownControllerTest do
use ToweropsWeb.ConnCase, async: true
describe "GET /.well-known/api-catalog" do
test "returns linkset+json with API entries", %{conn: conn} do
conn = get(conn, "/.well-known/api-catalog")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["application/linkset+json; charset=utf-8"]
body = json_response(conn, 200)
assert is_list(body["linkset"])
[entry] = body["linkset"]
assert entry["anchor"] =~ "/api/v1"
assert Map.has_key?(entry, "service-doc")
end
end
describe "GET /.well-known/openid-configuration" do
test "returns OIDC metadata", %{conn: conn} do
conn = get(conn, "/.well-known/openid-configuration")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "issuer")
assert Map.has_key?(body, "authorization_endpoint")
assert Map.has_key?(body, "token_endpoint")
assert Map.has_key?(body, "jwks_uri")
end
end
describe "GET /.well-known/oauth-authorization-server" do
test "returns OAuth 2.0 metadata", %{conn: conn} do
conn = get(conn, "/.well-known/oauth-authorization-server")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "issuer")
end
end
describe "GET /.well-known/oauth-protected-resource" do
test "returns protected resource metadata", %{conn: conn} do
conn = get(conn, "/.well-known/oauth-protected-resource")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "resource")
assert Map.has_key?(body, "authorization_servers")
end
end
describe "GET /.well-known/mcp/server-card.json" do
test "returns MCP server card", %{conn: conn} do
conn = get(conn, "/.well-known/mcp/server-card.json")
assert response(conn, 200)
body = json_response(conn, 200)
assert get_in(body, ["serverInfo", "name"]) == "TowerOps"
assert Map.has_key?(body, "transport")
end
end
describe "GET /.well-known/agent-skills/index.json" do
test "returns agent skills index", %{conn: conn} do
conn = get(conn, "/.well-known/agent-skills/index.json")
assert response(conn, 200)
body = json_response(conn, 200)
assert Map.has_key?(body, "$schema")
assert is_list(body["skills"])
end
end
end
Step 2: Run tests to verify they fail
mix test test/towerops_web/controllers/well_known_controller_test.exs
Expected: All FAIL with no route error.
Step 3: Add routes to router.ex
After the sitemap scope, add a scope for well-known endpoints. Note the nested path for MCP:
# Well-known discovery endpoints (no pipeline — public, no CSRF)
scope "/.well-known", ToweropsWeb do
get "/api-catalog", WellKnownController, :api_catalog
get "/openid-configuration", WellKnownController, :openid_configuration
get "/oauth-authorization-server", WellKnownController, :oauth_authorization_server
get "/oauth-protected-resource", WellKnownController, :oauth_protected_resource
get "/agent-skills/index.json", WellKnownController, :agent_skills
end
scope "/.well-known/mcp", ToweropsWeb do
get "/server-card.json", WellKnownController, :mcp_server_card
end
Step 4: Create the controller
# lib/towerops_web/controllers/well_known_controller.ex
defmodule ToweropsWeb.WellKnownController do
@moduledoc false
use ToweropsWeb, :controller
def api_catalog(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
conn
|> put_resp_content_type("application/linkset+json")
|> json(%{
"linkset" => [
%{
"anchor" => "#{base_url}/api/v1",
"service-doc" => [%{"href" => "#{base_url}/docs/api"}],
"service-desc" => [
%{"href" => "#{base_url}/docs/api", "type" => "text/html"}
],
"status" => [%{"href" => "#{base_url}/health"}]
}
]
})
end
def openid_configuration(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"issuer" => base_url,
"authorization_endpoint" => "#{base_url}/oauth/authorize",
"token_endpoint" => "#{base_url}/oauth/token",
"jwks_uri" => "#{base_url}/.well-known/jwks.json",
"grant_types_supported" => ["authorization_code"],
"response_types_supported" => ["code"]
})
end
def oauth_authorization_server(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"issuer" => base_url,
"authorization_endpoint" => "#{base_url}/oauth/authorize",
"token_endpoint" => "#{base_url}/oauth/token",
"jwks_uri" => "#{base_url}/.well-known/jwks.json",
"grant_types_supported" => ["authorization_code"],
"response_types_supported" => ["code"]
})
end
def oauth_protected_resource(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"resource" => "#{base_url}/api/v1",
"authorization_servers" => [base_url],
"scopes_supported" => ["read", "write"]
})
end
def mcp_server_card(conn, _params) do
base_url = ToweropsWeb.Endpoint.url()
json(conn, %{
"serverInfo" => %{
"name" => "TowerOps",
"version" => "1.0.0"
},
"transport" => %{
"endpoint" => "#{base_url}/mcp"
},
"capabilities" => %{
"tools" => %{},
"resources" => %{},
"prompts" => %{}
}
})
end
def agent_skills(conn, _params) do
json(conn, %{
"$schema" => "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills" => []
})
end
end
Step 5: Run tests
mix test test/towerops_web/controllers/well_known_controller_test.exs
Expected: All PASS.
Step 6: Commit
git add lib/towerops_web/controllers/well_known_controller.ex \
test/towerops_web/controllers/well_known_controller_test.exs \
lib/towerops_web/router.ex
git commit -m "feat: add /.well-known discovery endpoints for agent readiness"
Task 3: Link Response Headers on Homepage (RFC 8288)
Files:
- Modify:
lib/towerops_web/controllers/page_controller.ex - Modify:
test/towerops_web/controllers/page_controller_test.exs
Step 1: Write the failing test
Add to the existing page_controller_test.exs (find the GET / section and add):
describe "GET / link headers" do
test "homepage includes RFC 8288 Link headers", %{conn: conn} do
conn = get(conn, ~p"/")
link_headers = get_resp_header(conn, "link")
assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="api-catalog")))
assert Enum.any?(link_headers, &String.contains?(&1, ~s(rel="service-doc")))
end
end
Step 2: Run tests to verify they fail
mix test test/towerops_web/controllers/page_controller_test.exs
Expected: New test FAILS (no Link headers on current response).
Step 3: Update PageController
Modify home/2 in lib/towerops_web/controllers/page_controller.ex:
def home(conn, _params) do
conn = put_link_headers(conn)
if conn.assigns.current_scope && conn.assigns.current_scope.user do
redirect(conn, to: ~p"/dashboard")
else
render(conn, :home, layout: false)
end
end
defp put_link_headers(conn) do
conn
|> put_resp_header("link", ~s(</.well-known/api-catalog>; rel="api-catalog"))
|> put_resp_header("link", ~s(</docs/api>; rel="service-doc"))
|> put_resp_header("link", ~s(</health>; rel="status"))
end
Note: Phoenix put_resp_header/3 replaces a header. To emit multiple Link headers, use Plug.Conn.put_resp_header/3 only once with a comma-joined value instead:
defp put_link_headers(conn) do
link_value =
[
~s(</.well-known/api-catalog>; rel="api-catalog"),
~s(</docs/api>; rel="service-doc"),
~s(</health>; rel="status")
]
|> Enum.join(", ")
put_resp_header(conn, "link", link_value)
end
Step 4: Run tests
mix test test/towerops_web/controllers/page_controller_test.exs
Expected: All PASS.
Step 5: Commit
git add lib/towerops_web/controllers/page_controller.ex \
test/towerops_web/controllers/page_controller_test.exs
git commit -m "feat: add RFC 8288 Link response headers to homepage"
Task 4: Markdown Content Negotiation
When a request has Accept: text/markdown, return a markdown representation of the page with Content-Type: text/markdown.
Files:
- Modify:
lib/towerops_web/controllers/page_controller.ex - Create:
lib/towerops_web/controllers/page_html/home.md.eex(or inline in controller) - Test:
test/towerops_web/controllers/page_controller_test.exs
Step 1: Write the failing test
Add to page_controller_test.exs:
describe "GET / markdown negotiation" do
test "returns markdown when Accept: text/markdown", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"]
body = response(conn, 200)
assert body =~ "# TowerOps"
end
end
Step 2: Run to verify it fails
mix test test/towerops_web/controllers/page_controller_test.exs
Expected: FAIL — response returns HTML, not markdown.
Step 3: Update PageController
Note: The browser pipeline has plug :accepts, ["html"] which will reject non-HTML requests before they reach the controller. To work around this, check the accept header before format negotiation, or add the markdown check early.
The cleanest approach: add "md" to accepted formats and register the MIME type. But the simpler approach is to check the header directly at the top of home/2 before the pipeline can reject it. Actually, the :accepts plug runs in the pipeline before the controller. We need a different route.
Solution: Add a separate scope for markdown negotiation that bypasses the html-only browser pipeline:
In router.ex, add before the browser-pipeline scope:
# Markdown content negotiation (must come before :browser scope)
pipeline :markdown do
plug :accepts, ["html", "text/markdown"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ToweropsWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers, %{}
plug ToweropsWeb.Plugs.CaptureTimezone
plug :fetch_current_scope_for_user
plug :set_user_locale
plug ToweropsWeb.Plugs.UpdateSessionActivity
plug :store_return_to_for_liveview
plug ToweropsWeb.Plugs.DetectEUUser
plug ToweropsWeb.Plugs.CheckPolicyConsent
end
Actually, the simplest approach is to not use a pipeline at all for the markdown route, and instead add a dedicated action that handles markdown and sits outside the :accepts restriction. We can match on a different path...
Actually simplest: use the :accepts plug but modify the browser pipeline to also accept "text/markdown" mapped to a "md" format, then check format in the controller.
Even simpler: Add the markdown check before the :accepts plug runs, via a custom plug or by checking get_req_header in an early plug.
Recommended approach: Create a ToweropsWeb.Plugs.MarkdownNegotiation plug that runs early (before :accepts), detects Accept: text/markdown, intercepts only the homepage, and returns markdown directly.
# lib/towerops_web/plugs/markdown_negotiation.ex
defmodule ToweropsWeb.Plugs.MarkdownNegotiation do
@moduledoc false
import Plug.Conn
@markdown_pages %{
"/" => &__MODULE__.home_markdown/0,
"/privacy" => &__MODULE__.privacy_markdown/0,
"/terms" => &__MODULE__.terms_markdown/0
}
def init(opts), do: opts
def call(conn, _opts) do
if accepts_markdown?(conn) && Map.has_key?(@markdown_pages, conn.request_path) do
markdown_fn = @markdown_pages[conn.request_path]
markdown = markdown_fn.()
conn
|> put_resp_content_type("text/markdown")
|> send_resp(200, markdown)
|> halt()
else
conn
end
end
defp accepts_markdown?(conn) do
conn
|> get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "text/markdown"))
end
def home_markdown do
"""
# TowerOps
Network monitoring and alerting platform built for WISP and ISP operators.
See the business impact of every network event.
## Features
- Real-time network monitoring
- Subscriber impact analysis
- Revenue impact tracking
- Equipment management
- Multi-site support
## Get Started
- [Register](/users/register)
- [Log In](/users/log_in)
- [API Documentation](/docs/api)
- [Privacy Policy](/privacy)
- [Terms of Service](/terms)
## API
TowerOps provides a REST API for programmatic access.
See [API documentation](/docs/api) for details.
## Contact
Visit [towerops.net](https://towerops.net) for more information.
"""
end
def privacy_markdown do
"# Privacy Policy\n\nSee [towerops.net/privacy](https://towerops.net/privacy) for the full privacy policy.\n"
end
def terms_markdown do
"# Terms of Service\n\nSee [towerops.net/terms](https://towerops.net/terms) for the full terms of service.\n"
end
end
Add this plug to the endpoint (lib/towerops_web/endpoint.ex) before the router, so it runs before the :accepts pipeline check:
# Add before plug ToweropsWeb.Router
plug ToweropsWeb.Plugs.MarkdownNegotiation
plug ToweropsWeb.Router
Step 4: Update the test
The test in page_controller_test.exs will work with ConnCase which bypasses the endpoint. Modify the test to use build_conn() and the endpoint dispatch:
Actually in ConnCase, tests go through the full router pipeline. The plug being at the endpoint level means it runs before the router, but ConnCase dispatches through ToweropsWeb.Endpoint. Verify with:
describe "GET / markdown negotiation" do
test "returns markdown when Accept: text/markdown", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/markdown")
|> get(~p"/")
assert response(conn, 200)
assert get_resp_header(conn, "content-type") == ["text/markdown; charset=utf-8"]
body = response(conn, 200)
assert body =~ "# TowerOps"
end
end
Step 5: Run tests
mix test test/towerops_web/controllers/page_controller_test.exs
Expected: All PASS including the markdown test.
Step 6: Commit
git add lib/towerops_web/plugs/markdown_negotiation.ex \
lib/towerops_web/endpoint.ex \
test/towerops_web/controllers/page_controller_test.exs
git commit -m "feat: add markdown content negotiation for agent compatibility"
Task 5: WebMCP JavaScript
Expose site tools to AI agents via navigator.modelContext.provideContext() on page load.
Files:
- Modify:
assets/js/app.js - Modify:
test/towerops_web/controllers/page_html_test.exs(or create a JS test if preferred)
Since we cannot write inline <script> tags in templates, add WebMCP initialization to assets/js/app.js. The navigator.modelContext API is browser-experimental; guard against missing support.
Step 1: Append to assets/js/app.js
Find the end of assets/js/app.js and add:
// WebMCP: expose site tools to AI agents (https://webmachinelearning.github.io/webmcp/)
if (typeof navigator !== "undefined" && navigator.modelContext) {
navigator.modelContext.provideContext({
tools: [
{
name: "navigate",
description: "Navigate to a page on TowerOps",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "The path to navigate to, e.g. /dashboard or /users/register"
}
},
required: ["path"]
},
execute({ path }) {
window.location.href = path;
}
},
{
name: "search_docs",
description: "Open the TowerOps API documentation",
inputSchema: {
type: "object",
properties: {}
},
execute() {
window.location.href = "/docs/api";
}
},
{
name: "register",
description: "Start the registration flow for a new TowerOps account",
inputSchema: {
type: "object",
properties: {}
},
execute() {
window.location.href = "/users/register";
}
}
]
});
}
Step 2: Verify it compiles
mix assets.build
Expected: No JS errors.
Step 3: Run full test suite to ensure nothing broken
mix test
Expected: All PASS.
Step 4: Commit
git add assets/js/app.js
git commit -m "feat: add WebMCP tool definitions for AI agent browser interaction"
Task 6: Run precommit and fix any issues
Step 1: Run precommit
mix precommit
Expected: Compiles, formats, all tests pass.
If any Credo issues: fix them and run again.
Step 2: Final commit if any format fixes
git add -p # stage only format changes
git commit -m "chore: run mix format"
Verification Checklist
After all tasks complete, these endpoints must all return HTTP 200:
GET /sitemap.xml→text/xml, lists canonical URLsGET /.well-known/api-catalog→application/linkset+jsonGET /.well-known/openid-configuration→ JSON withissuerGET /.well-known/oauth-authorization-server→ JSON withissuerGET /.well-known/oauth-protected-resource→ JSON withresourceGET /.well-known/mcp/server-card.json→ JSON withserverInfoGET /.well-known/agent-skills/index.json→ JSON with$schemaGET /→ includesLinkheader withrel="api-catalog"GET /withAccept: text/markdown→text/markdownresponsepriv/static/robots.txt→ containsSitemap: https://towerops.net/sitemap.xml