A discovery file is a small file at a known path that tells an AI agent what a site can do. The agent trusts it and calls what it names, so every entry is a promise. On 2026-10-29, my site went from level 4 to level 5, “Agent-Native”, on isitagentready.com. It publishes nothing false. This essay covers four things. Why I refused to publish the missing cards. What I built instead. Which checks still fail on purpose. And the three failures that taught me the most.
Level 4 was the honest ceiling for files alone
The scanner runs 23 checks in five groups. Over four pull requests, I passed every check that describes content my site has. They include Content Signals, Markdown negotiation at the CDN, RFC 8288 Link headers, an ARD catalog, an Agent Skills index and read-only WebMCP tools. Each one points at content the site already serves.
That gave me level 4, “Agent-Integrated”. The scan still listed seven failures. Two were OAuth checks. The others were the api-catalog, auth.md, an MCP server card, an A2A agent card and DNS-AID. I skipped all seven on purpose. The site had no API, no login, no MCP server and no agent. A file for any of them would describe something that did not exist.
”Just make the cards exist” is a lie the scanner might not catch
The shortcut was obvious: write the two cards as JSON files and let the scan pass. I rejected it for two reasons.
First, it is dishonest. An MCP server card with a URL in it says “a server answers here”. An agent that trusts the card calls the URL and gets an error. My site lied to that agent, and the agent cannot know it.
Second, the score will not stop you. The scanner can call the server too. On another site, I saw it send initialize and tools/list to the URL in an MCP card. On my own card, it only read the file. I never tested whether a card with no server behind it fails the check. So the score does not force honesty. I have to choose it: build the real thing, then publish the card.
What I built: one source of truth, four readers
A static read-only JSON API. The build now writes /api/posts.json, one /api/posts/<slug>.json per post, /api/cv.json and /api/talks.json. They come from the same content collection and data files as the HTML pages, so they cannot drift from them. An OpenAPI 3.1 document describes them with security: []. The RFC 9727 api-catalog lists the API at /.well-known/api-catalog, served as application/linkset+json. And /auth.md says that the API needs no account, key, token or registration.
Even this static part had a surprise. The api-catalog path has no file extension, and my CloudFront function rewrites every path without a dot to /index.html. The fix was an exact-path allowlist, plus a separate aws s3 sync step that forces the right media type for that one file.
Real MCP and A2A servers on one Lambda. A new CDK stack, AgentsStack, puts an API Gateway HTTP API on agents.pabloalbaladejo.com. Behind it is one Node 22 Lambda with zero runtime dependencies. /mcp is an MCP server with five read-only tools: list_posts, get_post, search_posts, get_cv and list_talks. /a2a is an A2A 1.0 agent with four skills. The Lambda reads the site’s own /api/*.json and nothing else. So the pages, the JSON API, MCP and A2A all read one source.
I kept it off the main CloudFront distribution for one reason. Origin access control on a Lambda function URL needs an x-amz-content-sha256 header on POST, and MCP clients do not send it.
MCP changed in July 2026. The 2026-07-28 version is stateless and has no initialize step. On another site, the scanner probed the legacy way. So the server speaks five versions: the new stateless one, and four legacy ones that start with initialize. The tests run the official MCP v2 and v1 SDK clients against it in-process.
Limits live in code and in a runbook. The stage throttle is 2 requests per second with a burst of 5. Reserved concurrency is 5, the Lambda timeout is 5 seconds, and the body cap is 16 KB. A $5 monthly budget sends an email, and an alarm fires at 20,000 requests a day. A written kill switch says how to stop it all. There is no WAF. The worst-case cost is about $51 a month, with a condition I explain below.
Why the A2A agent has no LLM
The four A2A skills are search posts, get one post, get the CV and list talks. Plain code does each of them. The search_posts tool even says so in its description: “The search uses plain word matching, not an AI model.”
I did consider an LLM behind the agent, and I rejected it for three reasons.
- It is honest. The agent does exactly what its card says. It cannot invent a post or misquote my CV, because it only returns text I published.
- Nothing in it can be injected. Nothing reads the content and acts on it. There are no actions and no secrets to leak.
- Nobody can run up a token bill on it. This is a public endpoint with no login. An LLM behind it would be a cost that anyone on the internet could spend.
An agent is a protocol and a contract. It does not have to be a model. The tasks are stateless too. A task id is t1. plus the task itself in base64url, so GetTask works with no database. CancelTask answers “not cancelable”, because every task is complete before the reply goes out.
What still fails on purpose
The scan on 2026-10-29 shows 13 checks pass, 4 fail and 6 are neutral. The neutral ones are Web Bot Auth and five commerce checks. All four failures are on purpose:
oauthDiscoveryandoauthProtectedResource. RFC 8414 metadata describes a real authorization server with a real issuer. My site has none. A file here would advertise a login that does not exist. The repo’s review rules now treat any/.well-known/oauth-*file as a finding.authMd. Myauth.mdis honest and complete, and the scanner still fails it withcheck.authMd.registrationMissing. It wants agent registration, and on a site with OAuth metadata it also wants anagent_authblock there. A public read-only site has neither.dnsAid. The endpoints behind it are now real. On 2026-10-29 I also published the three SVCB records (_mcp,_a2aand_indexunder_agents). The scanner finds all three and reports no format issues. It still fails the check withdnssecNotValidated, because my zone is not signed. I chose not to sign it. One DNSSEC mistake can take the whole domain offline for up to a day for many users. That risk is too high for one scanner check.
I also misread the scanner once. Its nextLevel field listed authMd as a requirement for level 5, so I thought level 5 needed a passing auth.md. It did not. Level 5 came after the MCP card, the A2A card and the api-catalog went live. The skill file says level 5 needs “2 of 3” of these: “Web Bot Auth, all integrations, auth metadata (OAuth or Auth.md)”. My site has all four integrations. Web Bot Auth is neutral, and auth metadata fails. I do not know exactly how the scanner counts my case, so I report only what the scan returns.
The failures that taught me the most
The cold start: 2 seconds plus 60 seconds made a minute of failure
The Lambda first ran at 128 MB. On a fresh instance, the first fetch to the site took longer than my 2-second fetch timeout. CloudWatch showed 2036, 2079 and 2261 ms. All three sit at the timeout, so I only know the real time was more than 2 seconds. Init took 129 to 163 ms, and later fetches on the same instance took 100 to 711 ms. So the slow part was CPU work on the first TLS handshake, not the network.
One slow fetch alone would have failed one call. The cache turned it into a minute of errors. I stored a failed read for 60 seconds, so every call on that instance failed until it expired. I saw it while I ran the new live checks against production for the cards PR.
The fix had two parts. Transient errors (a timeout, a network error, a 5xx or a 429) now live in the cache for only 5 seconds:
export const TRANSIENT_TTL_MS = 5_000;
A 404, a bad JSON body or a redirect still lives 60 seconds. And the memory went from 128 MB to 256 MB, because Lambda CPU scales with memory. The cold fetch is now about 1071 ms and a warm call about 150 ms. The price: the worst-case monthly cost went from about $29 to about $51.
Adversarial review found 500s that the unit tests missed
The first version of the server had 105 new tests, and they all passed. Then a review tried to break it on purpose. Two review rounds before the merge found three real 500s:
- A forged task id with a huge timestamp made
toISOStringthrow. - A message history nested 7000 arrays deep overflowed the stack when the agent echoed it back.
String()on a deep array inside an error message did the same.
Each one got a fix. A task id now carries a time bound on its timestamp. The echo returns only allowlisted fields. A type-aware clip() wraps every client value inside an error message. And a stack overflow now returns a 400. My unit tests checked the inputs I had imagined. The review checked the inputs a hostile client sends.
The certificate needed about two minutes
After the deploy, the new API Gateway custom domain served the wrong certificate for about two minutes. Some IP addresses failed TLS at first. Nothing was wrong with the code. Next time, I will poll the domain until TLS is clean, and only then run the smoke tests.
Guards that keep the promise true
A card that is true today can become false next month. Two guards stop that.
The cards come from the server’s manifest. manifest.json lives with the Lambda, and the Lambda reads it to answer tools/list. The site imports the same file to build both cards:
import manifest from "../../infra/lambda/agents/manifest.json" with { type: "json" };
There is no copy to keep in sync. One rule covers the gap between two deploys: the site deploys on merge, but the Lambda deploys only with a manual cdk deploy. So a manifest change goes to the Lambda first, and I merge it second. That way no card ever names a tool the server does not have.
A live round trip checks production. A site-health script calls the real server. It lists the tools over MCP and compares them with the card. It sends real A2A messages. It checks that the cards agree with each other and with what the server answers. The last run passed all 186 checks. That includes the 5 MCP rows, the 6 A2A rows and the 25 card-consistency rows. Tests on the card generator cannot catch a deploy that serves the wrong thing. Only a live call can.
The kill switch has a step for the cards too. If I ever switch the servers off, the next step is a PR that removes the cards. A card may not outlive its server.
What this does not prove
Level 5 is one scanner’s score. It shows that the servers answer. It does not show that any agent uses them.
You cannot read most of the code. The site’s repository is private. But every surface in this essay is public: you can fetch the MCP card and the A2A card, and call the servers they name.
Nobody outside has checked that my servers follow the specs. I wrote both by hand, with no SDK in the Lambda, and both specs still change. Both cards carry two shapes for now, the scanner’s fields and the newer spec fields, and I will need to check them again.
Finally, the cost bound has a condition. About $51 a month is the ceiling only if API Gateway does not bill the 429s it throttles. I have not confirmed that. Until I do, the alarm and the kill switch are the real control.
A checklist you can lift
- Ship the server first and the card last.
- Build both cards from the file the server reads. One manifest cannot drift.
- Cache a timeout for seconds, not for a minute. Only a permanent error, like a 404, deserves the long TTL.
- When a check needs a false file to pass, let it fail and write down why.
A discovery file is a promise to an agent you will never meet, so build the thing before you make the promise.
Related writing
- GEO: how AI agents find you: the first GEO round, on kaiord.com, at the files-only layer that this essay goes past.
- A tolerant OAuth server validates your broken client: the OAuth surface a real login needs, and so the reason my site publishes none of it.
- A gate is only as real as its run: the same rule for CI. A green status is also a promise that something has to check.