Blog

The three peer apps — web, MCP, API as architecturally peer surfaces

The three peer apps — web, MCP, API as architecturally peer surfaces

AILK ships three first-class surfaces in one monorepo — apps/web for humans, apps/mcp for agents, apps/api for programmatic callers. One-to-one parity, CI-enforced. Not a plugin stack. Not a bolt-on.

Most website starters treat the API as a thin retrieval layer over a React app. Agents that try to consume those sites end up scraping HTML, guessing at endpoints, and missing JSON-LD. AILK inverts that — the agent experience is a co-equal surface from day one.

Why three apps instead of one

The standard model: one Next.js app, a route handler or two, a database ORM imported directly in the server component. It ships fast. It also collapses the surfaces. When everything goes through one app, "the API" is whatever the React app exposes, and "the agent surface" is whatever the API happens to emit when a bot hits it.

AILK makes a different bet: the agent experience (AX) is a co-equal product surface, not a side panel bolted onto a website. The full reasoning is in ADR 0002 — Headless-first Architecture. The short version: if the AX surface is derived from the UX surface, the AX surface degrades every time the UX is changed for human reasons. The only way to keep them in sync is to build them as peers.

Three apps. One monorepo. Each surface first-class.

Peer means structurally equal — not "also available," not "documented after the fact." Each app has its own entry point, its own test surface, and its own deployment target. apps/web can deploy to Vercel without apps/mcp. apps/mcp can run as a subprocess without apps/web. apps/api owns the business logic and serves both.

The constraint that enforces this is the apps-don't-import-apps rule, locked in architecture.yaml and checked on every push. If you import apps/api from apps/web at the module level, CI fails. Communication goes through HTTP via @ailk/api-client. The boundary is hard.

apps/web — the human surface

apps/web is the Next.js App Router site human visitors see. It handles NextAuth at /api/auth/*. It reads content from packages/content-adapters, which parses the MDX files in content/ against the Zod schemas in packages/validation. It does not own the data layer.

The MDX content model is typed. Every page has a type field that resolves to a schema.org type, a slug, a title, a description, and optional schema overrides. The build validates frontmatter against the registry — a missing or invalid field fails the compile, not silently. The page template emits JSON-LD from that frontmatter. No hand-rolled structured data in the MDX body. The structure comes from the type.

apps/web does not import @ailk/database. Full stop. Server components read from apps/api over HTTP via @ailk/api-client, even when both apps are co-deployed on the same Vercel project. The call goes through the API surface every time.

Why the indirection? Three reasons. First, apps/web has to work in standalone mode — a fresh fork with no database configured should be able to pnpm dev and serve content immediately. Second, apps/web can deploy and restart independently. Third, the canonical surface for data is the API, not the web app. If apps/web holds a shortcut to the DB, the AX surface is no longer canonical. The documented exception is apps/web/lib/prisma-for-auth.ts, which imports @ailk/database to construct the Better Auth adapter singleton. That import is deliberate, scoped to auth, and named explicitly in the architecture docs.

apps/api — the programmatic surface

apps/api is the Fastify REST API. It owns all business logic: Stripe webhooks, license validation, checkout creation, server-side analytics, email send. It is the only app that imports @ailk/database for general reads and writes. It is the only app that calls packages/stripe-client. It is the only caller of packages/email.

This concentration is intentional. The vendor isolation rules — Stripe SDK only inside packages/stripe-client, Resend and nodemailer only inside packages/email, database client only inside apps/api — mean that swapping a vendor is a change to one package, not a hunt through three apps. The architecture enforces this at CI.

The parity contract requires that every HTTP endpoint an agent could reasonably call has a 1:1 MCP tool in apps/mcp. apps/api is the source of truth for that contract. apps/mcp calls apps/api for write operations using @ailk/api-client. The MCP server never holds Stripe secrets. It delegates writes to the API.

apps/mcp — the agent surface

apps/mcp is the MCP server. Two transports, selected by MCP_TRANSPORT.

stdio (default): standard MCP convention for local agent use. The binary starts as a subprocess of the agent client — Claude Desktop, Cursor, and similar. No network binding. No auth required.

http: MCP Streamable HTTP transport (MCP spec 2025-03-26). Binds Fastify on MCP_HTTP_PORT (default 8080) at MCP_HTTP_PATH (default /mcp). The same createMcpServer() factory powers both transports. Only the network layer differs.

The HTTP transport adds CORS via @fastify/cors (writes require an explicit allowlist), rate limiting (60 req/min/IP for reads, 10 for writes, env-overridable), and abuse heuristics: UA deny list, honeypot field check, email validation, per-IP daily cap on capture_lead.

When MCP_PUBLIC_HTTP_URL is set in apps/web's environment, /.well-known/mcp.json includes the HTTP transport URL in its transports[] array. Discovering agents can find the server over the network without out-of-band configuration.

Customer deployment patterns: subdomain (recommended — mcp.customer.com pointing to apps/mcp HTTP), path via reverse proxy (customer.com/mcp → apps/mcp HTTP), or local subprocess (default stdio, no extra config). All three are documented in the architecture.

The parity contract — CI-enforced

Every MCP tool that touches business data has a 1:1 HTTP equivalent in apps/api. Every HTTP endpoint an agent could reasonably call has a 1:1 MCP tool in apps/mcp. This is the parity contract.

It is CI-enforced. The test runs on every push. Changing one side without the other in the same PR fails CI.

Why does this matter? If the MCP surface drifts from the HTTP surface, agents using the MCP server can do things the HTTP API cannot, or vice versa. The two surfaces become incoherent. Every hosted "agent integration" story that collapses collapses here — the API endpoint exists but has no MCP tool, or the MCP tool exists but calls a deprecated endpoint. The parity test prevents that category of drift at the commit level.

Without the parity contract, the "agent-callable" claim is provisional. If a developer adds an HTTP endpoint for a new workflow and skips the MCP tool, the agent surface silently falls behind. The parity test makes the gap visible before it ships. The test is part of pnpm preflight — same gate as architecture lint, type-check, and content validation.

Where the three surfaces connect: packages/

The three apps do not share code by importing each other. They share code through packages/. The packages layer sits upstream: never importing from apps/*, always importable by them.

Key packages in the peer-surfaces story:

packages/api-client — HTTP client used by apps/web (and apps/mcp for writes) to call apps/api. The boundary is HTTP, but the client is typed.

packages/auth-client — session and token verification consumed by apps/api and apps/mcp. The auth boundary lives here, not in each app.

packages/schema — schema.org JSON-LD builders. apps/web uses these to emit structured data. The same types power the ailk audit scoring in packages/aeo.

packages/validation — Zod schemas and shared types. Frontmatter validation, API request/response shapes, MCP tool argument types. One source of truth for the contract surface.

apps/* must not import other apps/. packages/ must not import from apps/*. These two rules, checked by tools/lint-architecture.ts against architecture.yaml, are what make the peer model real. Violate either and you have a monolith that happens to be split into directories. The rule is not a suggestion. It is what CI will reject. The architecture boundary is the test.

Frequently asked

Why not a single Next.js app with API routes and an MCP route handler?

Because route handlers in Next.js App Router collapse the surfaces. The API becomes whatever the web app exposes; the MCP surface becomes whatever you wire to a route handler. Both surfaces degrade when the web app changes for human-UX reasons. Separating the apps — and enforcing the boundary in CI — keeps each surface clean. apps/web can deploy without apps/mcp. apps/api can scale independently. The peer model requires the separation.

Does apps/mcp need to run for the site to work?

No. apps/web reads content from MDX and calls apps/api over HTTP. Neither depends on apps/mcp being available. The MCP server is an additional surface — not a dependency of the human experience. Forkers who do not need agent-callable tooling can leave apps/mcp undeployed. apps/web and apps/api work without it.

What happens if I add a new HTTP endpoint but forget the MCP tool?

The parity test in CI fails on the next push. It does not soft-warn; it exits non-zero and blocks the PR. The test compares the declared tool set in apps/mcp against the endpoint set in apps/api and requires one-to-one coverage. Internal-only endpoints that agents would never reasonably call can be explicitly annotated to skip the parity check.

Can apps/web and apps/mcp be deployed to the same domain?

Yes, via reverse proxy or path routing. The recommended pattern is a subdomain for apps/mcp — mcp.customer.com — because it keeps the MCP HTTP transport URL clean and avoids path-based routing complexity. Path routing (customer.com/mcp → apps/mcp HTTP) is documented and supported. Both patterns work with .well-known/mcp.json discovery.

Read the full foundation spec, or clone the OSS and see the three surfaces in the repo.