own-stack
serverclient island
render: static · ssg

The outer layers.

Two layers sit outside React, and we own them too. On top, your visitor's agent gets the same buttons your visitor has. Underneath, nginx decides how quickly each byte lands.

Agent-ready: WebMCP

WebMCP is a W3C draft. A page tells the agent in the visitor's browser which actions it offers, as tools, and the agent stops guessing at selectors. Each tool here calls the function the button calls, so there is one behaviour to reason about.

Declarative · three attributes

The guestbook form names itself as a tool and describes its fields. That is the whole integration. The agent fills the form in; the visitor still presses Sign.

<form toolname="sign_guestbook"
      tooldescription="Leave a short public note in the guestbook.">
  <input name="name" maxlength="40"
         toolparamdescription="Display name, max 40 characters" />
  <input name="message" maxlength="140"
         toolparamdescription="The note, max 140 characters" />
  <button>Sign</button>
</form>

Sign it yourself on /guestbook

Imperative · one island

Search answers as you type, so there is no form to annotate. A small island registers search_feed instead. Its execute calls searchFeed, the same server function the search box calls. Browsers without WebMCP skip it, and leaving the page unregisters the tool.

document.modelContext?.registerTool({
  name: 'search_feed',
  execute: async (input) => ({ items: await searchFeed(str(input.query, 80)) }),
}, { signal })

Search the feed on /search

An agent's input deserves the same care as anyone's. Both server functions narrow and cap what they receive on their first line: str(value, max), one helper, no validation library.

see the tools: Chrome 149+, enable chrome://flags/#enable-webmcp-testing, then run await document.modelContext.getTools() in the console

The network layer: nginx

Waku renders, nginx delivers. Three lines in the deploy config shape what a visit feels like.

immutable_paths: ["/assets/"]
Vite puts a content hash in every file name, so nginx tells the browser to keep those files for a year, immutable. A second visit downloads nothing it already has.
streaming: true
nginx passes each chunk on as it arrives. When one server component is slow, the rest of the page is already on screen. Watch the archive arrive on /feed.
sse_paths: ["/api/events"]
That path stays open for an hour, unbuffered and uncompressed. The server sends a comment line every 25 seconds so no proxy in between hangs up on a quiet stream.
home