I made my portfolio readable by AI agents. An agent-readiness scanner kept me honest.
How I took yonyon.ai from a 36/100 agent-readiness score to 73/100 (grade B, rank #89 of ~11,900) by adding llms.txt, a hand-rolled MCP server, NLWeb /ask, and fixing three embarrassing implementation bugs.

Background
My portfolio lives at yonyon.ai. It is the public face for my consulting work - building AI systems, autonomous agent pipelines, and the orchestration layer I open-sourced as OrchestKit. A few months ago I started wondering whether AI systems could actually read my site or whether they just got back a wall of JavaScript. I ran an agent-readiness scan using ora.ai's orank tool. Score: 36/100. Grade D.
That was enough motivation. Here is what I shipped over the following weeks, what broke in embarrassing ways, and where the score landed.
What agent-readiness actually means
The short version: can an AI system (crawler, LLM tool-caller, MCP client, NLWeb agent) navigate your site without a browser? "Readable by LLMs" is not just about having good copy. It means structured discovery surfaces, machine-usable endpoints, and content that exists in the raw HTML response rather than behind a hydration boundary.
orank tests roughly 80 signals across five tiers: discovery, content accessibility, semantic markup, protocol compliance, and agent tooling. Score 73/100 today, grade B, rank #89 of ~11,900 scanned sites. Started at 36. The gap between 36 and 73 is almost entirely implementation bugs, not missing features.
Discovery surfaces I added
/llms.txt - a plain-text index of the site's purpose and main sections, following the emerging llms.txt convention. Also /llms-full.txt with expanded content per section.
/index.md - a markdown version of the homepage. The server does Accept-header content negotiation: request with Accept: text/markdown and you get markdown back instead of HTML. Same URL, different representation.
.md twins for content pages - each major page has a parallel .md route. Agents can request the markdown version without guessing.
RFC 8288 Link headers on every response - pointing to the markdown twin, the llms.txt, and the MCP server card.
/developers.md - a machine index listing all agent-usable endpoints, their formats, and what they return.
The MCP server
The biggest addition: a Model Context Protocol server at /api/mcp. It speaks JSON-RPC 2.0 over Streamable HTTP. I did not use the official MCP TypeScript SDK. That was a deliberate call - mcp-handler has a peer dependency on zod 3, and this repo is on zod 4. Mixing zod majors in one bundle causes subtle runtime failures. So I wrote the JSON-RPC layer by hand. It is ~200 lines and handles all the protocol requirements.
Tools exposed: ask_yonatan (Q+A about my work and approach), browse_projects (list or fetch individual projects), book_intro_call (surface Cal.com booking link with context), read_site_page (fetch any page as markdown). Every tool has a typed inputSchema and MCP annotations declaring whether it is read-only, destructive, etc.
The server card lives at /.well-known/mcp/server-card.json - a JSON document describing the server, its tools, and how to authenticate (public, no auth required).
NLWeb /ask endpoint
NLWeb is Microsoft's protocol for natural-language queries over websites. It defines a simple POST /ask endpoint that accepts a query string and returns structured results, optionally as a server-sent events stream.
War story 1: my GET handler returned a self-describing descriptor object instead of an answer. The idea was that GET /ask without a query would explain how to use the endpoint - helpful for humans, fatal for scanners. Every orank probe of GET /ask?query=test concluded the endpoint did not work, because the response was a descriptor, not a result. The POST endpoint worked correctly the whole time. Fix: implement the shapes the reference client actually sends, not just the shapes the spec describes in prose. Score jumped several points the day I fixed this.
WebMCP: the hydration trap
War story 2: WebMCP exposes a navigator.modelContext interface for in-browser agent integration. I registered the handler inside a React useEffect. It worked fine in the browser. It was completely invisible to any crawler that reads raw HTML without executing JavaScript. Crawlers do not run useEffect.
Fix: server-render an inline script tag that registers the interface before hydration. The script runs during initial parse, before any JavaScript framework takes over.
The lesson generalizes: anything that only exists after a hydration boundary does not exist for agents. This includes most "dynamic" content, most analytics integrations, and most "client-side only" features. If discovery matters to you, render it on the server.
/status and the next-intl middleware trap
War story 3: I added a /status route for uptime monitoring and content negotiation diagnostics. It returned 404 in production for two days. The route file existed. The handler was correct. The reason: next-intl middleware locale-routes bare paths like /status to /en/status before they reach the route handler. The route handler's own code comment claimed "route handlers escape middleware rewriting" - that comment was wrong (or out of date). I had also set force-static on the route, which froze its content negotiation headers.
Fix: add /status to the next-intl middleware exclusion list and remove force-static. Verify with curl -I, not just a browser visit.
NLWeb Schema Feeds
orank checks whether your robots.txt declares a schemamap: directive pointing to a sitemap-format XML that lists JSONL feeds of schema.org JSON-LD objects. This is a lower-visibility signal but it matters for the semantic tier. The JSONL feed lets crawlers batch-ingest structured data about your site's entities without parsing HTML at all.
I added a sitemap-schema.xml and a /feeds/schema.jsonl endpoint. robots.txt now includes: schemamap: https://yonyon.ai/sitemap-schema.xml
The training-corpus trade-off
robots.txt ships a Content-Signal: ai-train=yes header and explicitly allows CCBot (the Common Crawl bot, which feeds many LLM training pipelines). This costs one orank point - the scanner's default assumption is that blocking AI crawlers is "safer". I disagree, at least for a personal brand site.
The whole goal of being agent-readable is that AI systems can accurately represent who I am and what I build. If I block training crawlers, I am not in the corpus, and the next LLM that someone asks about Yonatan Gross returns either nothing or noise. There is a musician who goes by YonYon. Without deliberate corpus presence, the name collision is a problem.
I took the point loss.
Error envelopes on every API path
Every API route - including 404 responses - returns a typed JSON error envelope: { "ok": false, "code": "NOT_FOUND", "message": "..." }. Agents branch on the code field, not the prose. Without a consistent envelope, an agent that hits a 404 gets back an HTML error page or an untyped JSON object and has to guess what happened.
This is a small change with outsized impact on agent usability. Add it early.
Current state and what is left
Score: 73/100, grade B, rank #89 of ~11,900. The remaining gap is mostly in areas I have consciously skipped: OAuth-protected resources (overkill for a personal portfolio), HTTP message signatures directory, and a few semantic markup signals that require schema.org types I have not implemented yet.
The scan is honest. Every time I thought "that should be working", the scanner found a real bug. Run it on your own site.
Links
yonyon.ai/developers - machine index of all agent endpoints. POST https://yonyon.ai/ask - NLWeb Q+A endpoint. github.com/yonatangross/orchestkit - the open-source orchestration layer. ora.ai orank - run the scan on your own site.
Want a system like this built for your product?
A free 15-minute intro call. No pitch deck, no pressure. Just whether I can help.