# How I Scored 100/100 on Is Agentic by Vercel

Canonical URL: https://www.harjotrana.com/blog/scoring-100-on-is-agentic
Author: Harjot Singh Rana
Published: 2026-08-23
Reading time: 21 min

> My portfolio started at 75 on Vercel's agent-readiness scanner. Two rounds later it scored 100. Here is every change that moved the number: Markdown content negotiation, a hand-rolled MCP server, RFC 9457 errors, RateLimit headers, an llms.txt that says when to use the site, and the one header Next.js would not let me set.

Vercel runs a scanner called [Is Agentic](https://is-agentic.com/scan/www.harjotrana.com). 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](https://www.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.

*Diagram: Three scores on a timeline: 75 at the first scan, 89 after round one, 100 after round two. Round one added Markdown negotiation, an MCP server, llms.txt guidance, and trust pages. Round two added a JSON API, RFC 9457 errors, RateLimit headers, and typed OpenAPI schemas.*

*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](https://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.

*Diagram: A request enters proxy.ts, which parses the Accept header. Three outcomes: text/html wins and the normal page renders; text/markdown wins and the request is rewritten to the internal Markdown route; the header refuses both types and a 406 problem response is returned. All three branches set Vary: Accept.*

*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.

```bash
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](https://modelcontextprotocol.io) 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`.

```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.

*Diagram: Two rows. Top row: for an HTML page, the proxy sets Vary: Accept, then the Next.js app-page handler calls res.setHeader('Vary') and overwrites it, and on Vercel function headers win over next.config headers, so the final response has no Accept in Vary. Bottom row: for a route handler such as the Markdown route, the handler sets its own Vary: Accept and it survives to the response.*

*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.

*Diagram: A single source box containing data.ts, config.tsx, content/blog, and the shared hire copy, with arrows fanning out to five surfaces: HTML pages, Markdown representations, llms.txt and llms-full.txt, the MCP tools, and the /api/v1 JSON endpoints.*

*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](https://www.rfc-editor.org/rfc/rfc9457) 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.

```json
{
  "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](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers) defines two structured fields:

```http
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 `operationId`s 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.

*Diagram: A grid of the surfaces the scanner probes, grouped in four columns: discovery files (llms.txt, sitemap.xml, robots.txt, .well-known/api-catalog), representations (Accept: text/markdown, .md suffix, 404 with Markdown body, Vary: Accept), APIs (openapi.json, /api/v1 JSON, problem+json errors, RateLimit headers), and MCP plus trust (POST /mcp, /mcp/server-card, .well-known/mcp.json, /about, /contact, /privacy).*

*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:

```bash
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](https://www.harjotrana.com/developers) documents every surface described here, and the contact options are one `curl https://www.harjotrana.com/api/v1/contact-options` away.

More writing: https://www.harjotrana.com/blog

---

Site guide for agents: https://www.harjotrana.com/llms.txt · Full site as Markdown: https://www.harjotrana.com/llms-full.txt · Sitemap: https://www.harjotrana.com/sitemap.xml · Developer resources: https://www.harjotrana.com/developers