How this notebook is built
Abstract. The site is a static Astro build on S3 and CloudFront, with a Markdown twin of every note for agents and one small HTTP service that does semantic search and speaks MCP. This note follows a request through the system, explains why that service runs on Lambda Web Functions behind origin access control, and shows how one push to main deploys all of it.
Two readers
Much of what reads this site will not be a person. Crawlers, coding agents and research tools fetch a page, strip the chrome, and try to recover the text. I would rather hand them the text. So every note exists twice: as the page you are reading, and as a plain Markdown file with the same content.11 Append .md to a note’s path, or send Accept: text/markdown to the normal URL. llms.txt lists every twin. The rest of the design follows from taking that second reader seriously without making the first one pay for it.
The request path
2.1 Static first. The site is a static Astro build. Pages, Markdown twins, the feed and /llms.txt [1] are plain files in one S3 bucket behind CloudFront, and nothing renders on request. Table 1 lists every path and the origin that answers it.
| path | origin | serves |
|---|---|---|
/, /posts/<slug>/ | S3 | HTML |
/posts/<slug>/, Markdown accepted | S3 | the .md twin |
/posts/<slug>.md, /llms.txt | S3 | Markdown for agents |
/api/search?q= | Web Function | search results, JSON |
/mcp | Web Function | MCP, Streamable HTTP |
Table 1. Every route on the site and the origin behind it.
2.2 Negotiation at the edge. A CloudFront Function runs on every viewer request to the static origin. When a request for a note asks for Markdown, it rewrites the path to the twin before the cache lookup, so the two representations are cached separately and neither needs a server. Listing 1 is the whole idea.
function handler(event) {
var req = event.request;
var uri = req.uri;
var accept = req.headers.accept ? req.headers.accept.value : '';
// Agents asking for markdown get the post's .md twin
if (uri.startsWith('/posts/') && accept.includes('text/markdown') && !uri.endsWith('.md')) {
req.uri = uri.replace(/\/$/, '') + '.md';
return req;
}
// Pretty URLs map to index.html; the S3 origin has no index documents
if (uri.endsWith('/')) req.uri = uri + 'index.html';
else if (!uri.includes('.')) req.uri = uri + '/index.html';
return req;
}Listing 1. The viewer-request router, excerpted. Line 7 is the content negotiation.
One service, two doors
The dynamic part is small: a search endpoint for the page’s own tools, and a remote MCP server [2] with two tools, search_posts and get_post_markdown. Both live in one Express app. CloudFront sends /api/* and /mcp to it, as Figure 1 shows.
3.1 Why Web Functions. The app runs on Lambda Web Functions [3], which give an ordinary HTTP server requests directly, on Node.js 24 and Arm64, with no event-handler rewrite and no adapter in front of Express. What matters most here is that the endpoint can require IAM auth and sit behind CloudFront origin access control. CloudFront signs every request it forwards, and signs POST bodies as UNSIGNED-PAYLOAD,22 Behind origin access control, a Lambda function URL needs the client to send the body’s SHA-256 in x-amz-content-sha256. MCP clients don’t, which is what pushed earlier versions of this design to API Gateway. so an MCP client sends an ordinary JSON-RPC POST and nothing more.
That removed three things an earlier version of this design needed: an API Gateway in front of the function, the Lambda Web Adapter inside a container image, and a shared-secret header that CloudFront added and the app checked. The function’s resource policy now names the distribution, and Lambda refuses any request that doesn’t come through it before my code runs.
3.2 Search. Each deploy copies the Markdown twins to a second bucket and starts an ingestion job on a Bedrock knowledge base. It embeds chunks with Titan Text Embeddings V2 at 1024 dimensions and keeps them in S3 Vectors, which has no always-on minimum the way a search cluster does. Retrieval is purely semantic, so an exact library name can rank below a paraphrase of it.
Deploys
Everything above is declared in OpenTofu: DNS, buckets, the distribution, the function, the knowledge base and every role. State is encrypted on the runner with a key from KMS before it is written anywhere. GitHub Actions reaches AWS through OIDC with two roles: a read-only one that plans pull requests and checks for drift every night, and an apply role that only jobs in the protected production environment can assume.
A push to main builds the service into a zip named by the git tree hash of services/api, applies the infrastructure, publishes the site, re-ingests the knowledge base, and runs smoke tests against the live domain. When only prose changes, the tree hash stays the same and the function is left alone.
Remark 1. Whatever changes on every commit is an artifact; everything else is infrastructure. Where that line falls is the main design decision.
In the page
Pages also register three tools through WebMCP in browsers that support it: search_posts, get_post_markdown and list_posts. They call the same endpoints a remote agent would, so an agent working in the tab and one working over MCP see the same notebook.
References
[1] J. Howard. The /llms.txt file. llmstxt.org, 2024. llmstxt.org
[2] Model Context Protocol. Transports, Streamable HTTP. Specification, revision 2025-03-26. modelcontextprotocol.io
[3] Announcing AWS Lambda Web Functions. AWS News Blog, October 2026. aws.amazon.com
cite this note
Jack. How this notebook is built. Undue Attention, note 1, 2026.