logo
Published on

Structuring Software for Humans, MCP, and Peer Agents

artificial-intelligence
Authors

There is a quiet assumption baked into almost every app ever shipped: a human will use it. Someone sits down, opens a browser, reads your screens, clicks your buttons. Every architectural decision flows from that — auth lives in the session, workflow rules live in the frontend, "are you sure?" lives in a modal.

In 2026 that assumption is breaking. Not because humans stopped using software, but because they are no longer the only client. Connectors, MCP servers, and agent-to-agent protocols mean your app gets called by Claude, by ChatGPT, by another company's agent — none of which can see your screens, hold your session, or read your tooltips.

This is not "add an API." Most apps already have one. This is something sharper, and it changes the project structure:

Your UI is no longer the front door. It's one client among several — and the least demanding one.

The screen is the forgiving head. A human fills in the gaps your design left. An agent doesn't. Build for the agent and the human comes free; build for the human and the agent hits a wall. So the structure inverts. Below is the shape I now reach for by default, walked through with a deliberately boring CRUD example — a notes service — so the structure is the lesson, not the domain.


1. The Three Layers

Stop thinking "API for my frontend." Start thinking "capability surface, with the frontend as a reference consumer." Three layers, one direction of dependency:

heads/  ->  capabilities/  ->  core/

Nothing flows back up. core never imports a head. A head never imports another head.

core/ — pure domain. Business logic with zero transport assumptions. No HTTP, no MCP, no JSON, no React. If you imported a web framework here, you did it wrong. This is also where every invariant lives — and that placement is the whole game, which is why it gets its own section below.

capabilities/ — the contract. Named, described, side-effect-typed operations: create_note, archive_note, search_notes. This is what every head calls. Never the core directly.

heads/ — thin adapters. http/ for your UI, mcp/ for agent clients, acp/ for peer agents. Each one parses its own transport, resolves context, calls a capability, maps errors back. No business logic. If you're tempted to write an if about domain state inside a head, it belongs one layer down.

If your web app can do something your MCP server can't, you've split-brained your product. The three-layer split is what stops that from happening: there's only one place capabilities are defined, and every head reads from it.


2. Invariants Must Survive Losing the Screen

Here's the bug that defines this whole shift. Your UI "knows" you can't archive an already-archived note — the button is greyed out. That knowledge lives in React state. An agent calling your MCP server never sees that button. It calls archive_note on an archived note and either corrupts state or hits a confusing 500.

The rule:

A constraint that lives in UI state is a constraint an agent can bypass. Push every invariant down into the core.

In the notes service, the core enforces it directly and raises a typed error:

function archive_note(actor_id, id):
    note = repo.get(id) or raise NoteNotFound(id)
    if note.owner_id != actor_id:  raise NotOwner(id)
    if note.archived:              raise CannotArchiveArchived(id)
    ...

Now the greyed-out button is a nicety, not the enforcement. Every head — UI, MCP, peer agent — gets the same guarantee for free, because the guarantee lives below all of them. This is the single highest-leverage move in the structure. Get it wrong and every new head re-implements your business rules and drifts.


3. Tool Descriptions Are UX for a Reader With No Screen

A human gets onboarding, empty states, progressive disclosure, a tooltip on hover. An agent gets one paragraph of description and a JSON schema, and has to pick the right operation with nothing else. That description is your interface for the agent.

So write it like UX, because it is:

capability search_notes:
    side_effect: READ
    description: """
        Search a user's notes by free-text query. Read-only. Use this BEFORE
        create_note when the user might be referring to a note that already
        exists. Returns up to 50 matches, newest first.
    """

That "use this BEFORE create_note" line is the equivalent of a well-placed empty state nudging a human. Drop it and the agent creates duplicates. The discipline nobody warns you about: you are now writing copy for a user who reads only text and never forgives ambiguity.

And critically — you write it once. The MCP head doesn't re-describe the tools. It generates the tool list straight from the capability registry, so the string the agent reads is the exact string the contract defines.


4. Context Is Always Explicit

Your UI carries identity implicitly: session cookie, JWT, "current user" resolved from the request. An agent client has none of that. Every capability must take context explicitly — owner_id, org_id — and each head resolves it its own way:

  • HTTP head: owner_id from the session.
  • MCP head: owner_id injected from the MCP connection's auth — never assumed.
  • ACP head: owner_id delegated by the calling agent and verified, because a peer agent is a first-class but untrusted client.

The moment a capability reaches for an ambient session, it works in the UI and silently breaks for every agent. Make context a parameter, not an assumption.


5. Side Effects Are Declared, and Errors Are Recovery Instructions

Two things humans get from the screen that agents need from the contract.

Confirmation. A human gets an "are you sure?" modal. An agent needs that gate expressed structurally — a declared side effect and a dry-run path. When a gated write arrives without a confirmation token, don't silently perform it. Return the preview so the calling agent can decide whether to ask its human:

if cap.confirm and not args.confirmed:
    preview = cap.dry_run(args)
    return { status: "confirmation_required", preview: preview }

This generalizes the read/write tiering you'd already build into any agent with filesystem or shell access. Same idea: the destructive thing announces itself.

Errors. A 500 with a stack trace is fine for your logs and useless to an agent. Errors become part of the interface — typed, and ideally carrying the corrective action:

return { status: "error",
         code: "CannotArchiveArchived",
         recoverable: true,
         suggestion: "this note is already archived; nothing to do" }

The agent reads suggestion and recovers instead of looping. Your error surface is now a control surface.


6. The Payoff: One Test Suite

Because all three heads are thin adapters over one contract, you test the contract, not the heads. Verify the capability layer and every head is covered by construction. Your test surface collapses from three to one:

test "archive twice is a typed, recoverable error -- not a 500":
    n = capabilities.create_note(owner="u1", title="t", body="b").note
    capabilities.archive_note(actor="u1", id=n.id)
    err = capture capabilities.archive_note(actor="u1", id=n.id)
    expect err.code == "CannotArchiveArchived"

That test protects the UI, the MCP server, and the peer-agent head simultaneously. The structure pays you back.


The Structure, In One Screen

core/          pure domain. invariants + typed errors. no transport.
capabilities/  THE CONTRACT. named, described, side-effect-typed ops.
heads/
  http/        UI adapter        (context from session)
  mcp/         agent adapter     (context from MCP auth, explicit)
  acp/         peer-agent adapter (context delegated + verified)
tests/         test the contract, not the heads.

Five rules that survive losing the screen:

  1. Invariants live in core/, never in UI state.
  2. A capability description is UX for an agent — write it for a reader with no screen.
  3. Context is always explicit. No head assumes a session.
  4. Side effects are declared; gated writes return a dry-run, not a silent change.
  5. Errors are typed recovery instructions, not stack traces.

Closing thoughts

For many decades, "API-first" was advice you could safely ignore and still ship something people used. The human on the other end forgave you. They learned your quirks, worked around your dead ends, filled in what your design left unsaid. That grace period is over. The agent is a user who reads every word of your contract, remembers none of your intentions, and forgives nothing. It is the most literal code reviewer you will ever ship to — and it is about to be most of your traffic.

The teams that win the next few years won't be the ones who bolt an MCP server onto a finished app. They will be the ones who internalized that the contract is the product, and the screen is just the head that happens to have a face. Build that way and every new client — a model you have never heard of, a partner's agent, a protocol that does not exist yet — is a thin adapter and a day's work, not a rewrite. Build the contract first. Everything else is a head.


A reference repo with the full three-layer structure is available on Github