Vercel runs a scanner called Is Agentic. You give it a domain, an agent tries to use the site the way ChatGPT or Claude would, and you get a score out of 100 with a list of what broke. The checks are run by Ora; Is Agentic groups them and does the scoring.
My portfolio, harjotrana.com, scored 75 on the first scan. Two rounds of work later it scored 100. This post walks through every change that moved the number, the ones that did not, and the Next.js gotcha I lost an hour to.
The scanner's own task framing is the best explanation of why any of this matters. It asks an agent: "What does www.harjotrana.com do and who is it for? Explain it back to me." If that agent gets stuck, gets HTML it cannot parse, or gets a 200 for a page that does not exist, it quietly gives a worse answer about you. More and more first contact now happens through an agent. The site has to be legible to one.
Two rounds, one evening. Each finding in the report maps to a concrete change below.
How the score works
Checks are split into Essential (shared 80-point pool) and Recommended (shared 20-point pool), with a small bonus for emerging signals capped at five. Checks that do not apply to your site are excluded rather than counted against you, and partial results earn proportional credit. A personal portfolio with no API has fewer eligible checks than a SaaS product; once you publish an API, the API checks switch on and you have to pass those too. My eligible-check count went from 17 to 31 between the first and second scan for exactly that reason.
The first report had seven findings. Three were Essential: agent-friendly 404s (partial), content without JavaScript (partial), and Markdown content negotiation (failed). Four were Recommended: developer resource discoverability, an agent instruction file, an MCP manifest, and trust anchor pages.
Round one: 75 to 89
Markdown for any page, chosen by the Accept header
The biggest single failure was the simplest to state. An agent sending Accept: text/markdown got HTML back, and the Vary header did not mention Accept. The convention the scanner checks is acceptmarkdown.com: same URL, two representations, picked by content negotiation.
The site is Next.js 16 on Vercel, so the negotiation lives in proxy.ts (the file Next 16 renamed from middleware.ts). It parses the Accept header properly, with q-values and specificity, then does one of three things.
One URL, two representations. The HTML page and the Markdown route are different cache keys on Vercel, so a cached HTML page can never be served to a Markdown client.
The parsing detail that matters: the most specific media range wins regardless of q-value, so text/html;q=0, */* correctly rejects HTML even though the wildcard would accept it. Ties resolve toward HTML, so a browser's text/html, */*;q=0.8 and a bare */* both keep getting the page. Only a client that ranks text/markdown strictly above text/html gets Markdown.
The Markdown itself is not a scrape of the HTML. It is rendered from the same source the pages use: the project data module, the MDX files, and the shared copy for the hire page. Every document opens the same way, with an H1, a Canonical URL: line, and a blockquote lead, and closes with links to llms.txt, llms-full.txt, the sitemap, and /developers. An agent that lands on any one page can orient from it.
curl -s -H "Accept: text/markdown" https://www.harjotrana.com/hire | head -5
# # Work with Harjot Singh Rana - Product engineering for early-stage teams
#
# Canonical URL: https://www.harjotrana.com/hire
#
# > Bring me the idea. I'll help you ship the product.
A 404 an agent can recover from
The site already returned real 404s, which is the part most app shells get wrong. The scanner wanted more: a short body telling the agent where to go next. Unknown paths requested with Accept: text/markdown now return a 404 with a Markdown body that lists the main sections, the sitemap, llms.txt, and the developer page. The HTML 404 got the same links.
An llms.txt that says when to use the site
The site had an llms.txt before. The scanner failed it on one specific point: no when-to-use guidance. A summary of who you are is not the same as telling an agent which jobs you are the right answer for.
The fix was two sections. When to use this site names the jobs: finding a full-stack or AI product engineer for an early-stage product, adding an LLM or agent feature to an existing product, rescuing a fragile prototype, fractional ownership for a small team, verifying facts about me, reading the technical writing. It also has a Not a fit line, because an agent that knows what you do not do recommends you more precisely. How agents should call this site then lists the Markdown negotiation, the MCP server, the JSON API, and the rule that there is no write API, so contact goes through the human links.
An MCP server in 300 lines, no SDK
The report said "MCP mentioned on site but no standard manifest endpoint found." The site had no MCP server at all; it had blog posts about MCP. So I built one.
It is a read-only Model Context Protocol server over Streamable HTTP at POST /mcp, written as a plain JSON-RPC dispatcher on top of Next.js route handlers. No SDK, no sessions, no auth, no side effects. Eight tools: get_profile, get_services, list_projects, get_project, list_posts, get_post, get_contact_options, and search_site. Three resources: llms.txt, llms-full.txt, and openapi.json.
The discovery side is what the scanner actually probes. The server card follows the SEP-2127 schema at the reserved location <endpoint>/server-card, so https://www.harjotrana.com/mcp/server-card, served as application/mcp-server-card+json with CORS headers, an ETag, and 304 support. It is mirrored at /.well-known/mcp.json with a few extra top-level hints (url, transport, protocolVersion, and an mcpServers snippet you can paste into a client config), and listed in /.well-known/ai-catalog.json.
{
"mcpServers": {
"harjotrana": { "type": "http", "url": "https://www.harjotrana.com/mcp" }
}
}
I verified the handshake with the official TypeScript SDK rather than trusting my own reading of the spec: initialize, notifications/initialized, tools/list, tools/call, protocol version 2025-11-25, eight tools. The one transport I deliberately do not offer is the legacy HTTP+SSE one. It needs the server to hold a stream open in one request and receive messages in another, which does not work across serverless instances without a shared store. GET /mcp returns a 405 that explains this, which the Streamable HTTP spec explicitly allows.
Trust pages and a page that can be found by name
Agents check /about, /contact, and /privacy before recommending a business. Only the privacy policy existed. I added the other two, each over 500 characters of real content, and a /privacy redirect for tools that guess the short path.
The "developer resource discoverability" check searches the web for your product name plus "developer resources". The fix is a page whose title and H1 carry the name: harjotrana.com developer resources: API, MCP server, llms.txt. It lists every machine-readable surface with the URL, the format, and what it is for.
The header Next.js would not let me set
Here is the hour I lost. Markdown responses carried Vary: Accept. The HTML pages did not, and nothing I did changed that.
Headers you set before the page renders do not survive the page render. Headers a route handler sets itself do.
I tried the middleware response headers, a self-rewrite, and a headers() rule in next.config. Reading the compiled template finally explained it: Next 16's app-page handler calls res.setHeader('Vary', ...) unconditionally before rendering, so anything set earlier is replaced. On Vercel the headers() config did land on static files like the résumé PDF, but function responses win over config headers for the same key, so pages lost there too.
The honest outcome: the Markdown responses carry Vary: Accept because they are route handlers that set it themselves, and that is the response the scanner checks. The HTML pages cannot, and it is safe anyway because the Markdown variant lives at a different cache key and HTML is served must-revalidate. I wrote that down in the test file instead of pretending the header was there.
Round two: 89 to 100
Publishing an OpenAPI document in round one made the API checks eligible, and most of them failed. The second report had nine findings. Two were Essential: the heading-structure check (still partial) and JSON error responses (failed). The rest were about the API: typed error model, rate-limit headers, schema coverage, function-calling compatibility, docs linked from the homepage, and an MCP handshake the scanner could not complete.
One source, five surfaces
Before adding a JSON API I pulled the data access into one module, lib/site-api.ts, and made the MCP tools call it. The REST endpoints call the same functions. HTML, Markdown, llms.txt, MCP tools, and JSON now cannot disagree about a fact, because there is exactly one place a fact lives.
Five surfaces, zero drift. The HTML stays canonical; everything else is derived from the same files at build time.
The API is small and read-only: /api/v1 (an index), profile, services, projects, projects/{name}, posts, posts/{slug}, search?q=, and contact-options. Every response is application/json, CORS-open, cacheable, and carries a Link header to the OpenAPI document and the docs page.
Errors an agent can parse
The Essential failure in round two was the one I would have guessed last: "API does not return JSON error responses." Hitting /api/anything-unknown returned the site's HTML 404 page. Posting to a read-only endpoint returned Next's empty 405.
Every error is now an RFC 9457 problem-details object served as application/problem+json: type, title, status, detail, instance, plus two extension members, a stable code and a hint that says what to do next. A catch-all route under /api covers unknown paths. Read-only endpoints export 405 handlers with an Allow header. Even the 406 from content negotiation is a problem object now.
{
"type": "https://www.harjotrana.com/developers#error-not-found",
"title": "Not Found",
"status": 404,
"detail": "No endpoint at /api/nope.",
"instance": "/api/nope",
"code": "not_found",
"hint": "The public endpoints are listed in https://www.harjotrana.com/openapi.json and described at https://www.harjotrana.com/developers."
}
The type URIs resolve to anchors on the developer page, one per error code, so an agent that follows the link gets the documented meaning. In the OpenAPI document, a single Problem schema is referenced from every 4xx and 5xx response.
Rate-limit headers that follow the draft, with an honest caveat
The scanner wants the IETF RateLimit fields, and there is a trap here: the widely copied X-RateLimit-Limit / RateLimit-Remaining trio is not what the current draft says. draft-ietf-httpapi-ratelimit-headers-11 defines two structured fields:
RateLimit-Policy: "default";q=120;w=60
RateLimit: "default";r=117;t=59
q is the quota, w the window in seconds, r what remains, t seconds until the window resets. A 429 is a problem object with Retry-After, which the draft says takes precedence.
The limiter itself is an in-process bucket per client address wrapped around the API, MCP, and discovery handlers. On serverless hosting that makes it per instance, not a single global cap, and the developer page says so in plain words. Advertising a live, accurate signal from one instance is more useful to an agent than advertising nothing.
Typed schemas, not just schemas
The report's line "100% of operations define response schemas" next to "2/11 typed schemas" took a moment to decode. The checker counts an operation as typed only when its 200 response is application/json with an object schema. A text/plain document or a +json media type does not count.
Two changes fixed it. The discovery documents (openapi.json, the server card, the catalogs) now honor Accept: application/json and list both media types in the spec. And every JSON endpoint got a real object schema with named properties, required fields, and descriptions on every parameter. The result is 16 of 20 operations typed, against a 60% target, with unique operationIds and a description on each one, which is also what an LLM function-calling format wants.
Headings and link tags on the homepage
Two small ones. The scanner kept describing the homepage heading structure as flat even though the raw HTML had an H1, eight H2s, and six H3s under the first section. Roles, education, recognition, and post titles were styled spans. They are now H3s with the same classes; with Tailwind's preflight resetting heading margins and sizes, the computed styles are byte-for-byte identical and the outline went from one section with children to five.
The homepage also did not link to /developers, so the docs-linked-from-homepage check was partial. A footer link fixed that, and the <head> now carries rel="api-catalog", rel="service-desc" pointing at the OpenAPI document, rel="service-doc", and a rel="alternate" type="text/markdown" link on every page.
What the scanner actually probes
If you only read one section, read this one. Everything above reduces to a set of URLs and headers you can check with curl before you ever run the scan.
The surfaces behind the score. Most of them are an afternoon each; the order above is roughly the order of payoff.
The quick self-check, in the order I would do it:
D=https://www.harjotrana.com
curl -s -o /dev/null -w "%{http_code}\n" $D/some-path-that-does-not-exist # must be 404
curl -sI -H "Accept: text/markdown" $D/ | grep -i "content-type\|vary" # text/markdown + Vary: Accept
curl -s $D/llms.txt | grep -i "when to use" # guidance, not a bio
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" $D/api/nope # 404 application/problem+json
curl -sI $D/api/v1/profile | grep -i ratelimit # RateLimit + RateLimit-Policy
curl -s $D/mcp/server-card | head -3 # the MCP server card
What still reads partial, and why I left it
Honesty about the last few points matters more than the round number, so here is what the 100 still carries.
The heading-structure check still reports "5446 chars with H1 but flat heading structure", with the same character count as the very first scan, before the footer changed. It is reading cached evidence. The live page has an H1, eight H2s, and eighteen H3s.
Name-based search for "harjotrana developer resources" finds nothing because search engines have not indexed the new page yet. Nothing on the site can change that faster than time does.
Typed error model and function-calling compatibility still show partial because the checker does not follow $ref. Every operation references the shared Problem schema and a typed object; inlining them would raise the number without improving the API, so I did not.
A deprecation policy is documented with the exact headers it will use (Deprecation from RFC 9745, Sunset from RFC 8594), but the checker wants to see one on a live response. Nothing is deprecated, so there is nothing honest to emit.
A CLI tool would be a product, not a fix.
The part that generalises
The score is a side effect. The actual change is that the site now has one data layer and five faithful projections of it, and every one of those projections tells an agent where the others are. That is the property worth copying: not the endpoints, the fact that none of them can drift.
The whole thing took two evenings, with 65 unit tests and 37 black-box HTTP tests against the live domain to keep it honest. If you run the scan on your own site and want to compare notes, the developer page documents every surface described here, and the contact options are one curl https://www.harjotrana.com/api/v1/contact-options away.