Design: Boundary Coherence for AI-Agent-Led Development

Status: Draft, captures findings from the May 2026 redesign of @agentback/{rest, mcp, openapi}. Audience: Future contributors (human or AI) deciding what to add or change in this framework, and downstream users evaluating whether to adopt it for AI-led codebases. Last revised: 2026-05-21.

TL;DR

The framework's defensible value for large-scale AI-led TypeScript development is not any single feature (DI, Zod, OpenAPI emission, MCP support). It is the property that every boundary in the stack is the same artifact, viewed differently. A single Zod schema simultaneously is: a runtime validator, an z.infer-derived TS type, an OpenAPI parameter/body/response, an MCP tool input/output, and a Swagger/Inspector-rendered doc.

That artifact-coherence property is what gives AI agents — whose dominant failure mode is producing plausibly-typed code that diverges from the runtime contract — three orthogonal, localized failure signals (TS error at decoration, startup throw, behavioral test fail) instead of one ambiguous one (something is wrong, somewhere). The framework's design decisions are best evaluated by whether they preserve this property.

The thesis

Modern Node/TS API frameworks fall on a spectrum:

Code↔runtime Type↔runtime Service↔service Tool↔LLM Human↔docs
Express, raw req.body is any Hand-maintained types Hand-written OpenAPI Hand-written tool defs Hand-written README
tRPC Zod validation z.infer TS-only (no language-agnostic export) Custom adapter Hand-written
NestJS + DI class-validator Decorators on classes OpenAPI emission via @nestjs/swagger Custom adapter Swagger UI
FastAPI (Python) Pydantic Type hints are Pydantic OpenAPI auto-emitted Custom adapter Swagger UI
AgentBack (this) Zod z.infer derived OpenAPI 3.1 auto-emitted from same Zod MCP inputSchema/outputSchema from same Zod Swagger UI + MCP Inspector

The further down the table, the fewer distinct artifacts a contributor (or an AI agent) has to keep coherent. FastAPI's adoption among AI engineers is not coincidental: a Pydantic model is the validator, the type, and the OpenAPI parameter all at once. TypeScript has historically not had a clean version of this story; the framework's bet is that decorators carrying Zod schemas can supply it.

Three pillars and what each contributes

1. DI + extension points (@agentback/{context, core})

What it gives an agent:

What it costs an agent:

2. Zod (@agentback/openapi)

What it gives an agent:

What it costs an agent:

3. OpenAPI 3.1 emission (@agentback/openapi, mounted by @agentback/rest)

What it gives an agent:

What it costs an agent:

The "boundary coherence" insight

The genuinely novel value of the stack is not in any pillar individually; it is in the fact that the same Zod schema, declared once on the verb decorator (or @tool), services every boundary the agent might cross:

Boundary What the same schema becomes
Code → runtime Validator (safeParse)
Method signature → handler TS type (z.infer)
Service → service OpenAPI parameter / requestBody / response
Tool → LLM MCP inputSchema / outputSchema
Human → docs Swagger UI / MCP Inspector rendering

An agent reading or writing a controller has one place to look to understand all five. Compared to a typical Node/TS stack — TS types in types.ts, validators in validators.ts, OpenAPI in swagger.yaml, AI tools in tool-defs.ts, docs in README.md — that's a substantial reduction in surface area the agent has to keep coherent.

This is exactly the Python-FastAPI promise transplanted to TypeScript by way of decorators that carry Zod schemas. The framework's design decisions (the rewrites described below) preserve this property at compile time, so agents catch boundary mismatches at the decoration line instead of at the second integration test.

Why this is more aligned with spec/type-driven than test-driven development

The decorator is the spec:

@post('/items', {
  body: CreateItem,
  response: Item,
  responses: {404: {schema: NotFound}, 422: {schema: ValidationFailure}},
  status: 201,
})
async create(input: {body: z.infer<typeof CreateItem>}) {
  throw new Error('TODO');
}

By the time this compiles, you have committed to:

The implementation is the only thing missing. That is spec-first in the literal sense: the contract lands before the behavior, the contract is enforced by the compiler, and the spec emission happens for free.

The implementation cannot drift from the spec:

This is closer to type-driven development (Idris-style: the type carries enough information that the implementation is constrained) than to either pure spec-driven or pure TDD. The framework does not replace TDD; tests verify behavior (does the route persist the item, does auth reject wrong users, does pagination round-trip), while the contract layer absorbs the "is the shape right?" class of bugs.

The full cycle that emerges:

  1. Spec layer: write Zod schemas + decorator. One artifact.
  2. Type check: TS verifies the implementation matches the contract.
  3. Behavior tests: assert valid inputs produce correct outputs.
  4. Refactor: change the schema; type errors guide the edits; tests verify nothing semantic regressed.

Stages 1 and 2 are spec-driven; stage 3 is TDD; stage 4 leverages the type system to localize the work.

The agent ergonomics angle

Agents iterate best when failure signals are precise and localized. A spec-first framework gives the agent three distinct failure classes:

Signal What it points at Where it fires
TS error Implementation type drifted from schema Decoration line
Startup throw URL ↔ path-schema mismatch, slot-0 @inject collision, missing required binding app.start()
Test failure Behavioral bug Test runner

The agent gets three orthogonal red signals, each pointing at a different category of mistake. The schema-as-spec layer absorbs the "shape mistake" class entirely, so when a test fails the agent knows the bug is behavioral, not structural. That's a meaningful reduction in the search space the agent has to navigate.

This also means agent-led development can be schema-first, test-second:

  1. Agent writes Zod schemas + decorators (the spec).
  2. Agent runs pnpm build; the type system confirms the spec compiles.
  3. Agent writes tests against the declared contract.
  4. Agent writes the implementation. TS errors guide the structural work; tests guide the behavioral work.

Compared to an Express + raw-Zod stack where the agent has to keep the route handler, the validator, the type, and the OpenAPI doc in sync by hand: fewer sources of truth, less drift, fewer cycles.

Implementation evidence: what the framework does to preserve the property

The recent rewrites of @agentback/{rest, mcp, openapi} are all in service of preserving boundary coherence at compile time.

Slot-0 input bundle on verb and tool decorators

@get('/hello/{name}', {path: HelloPath, response: Greeting})
async hello(input: {path: z.infer<typeof HelloPath>}) { ... }

@tool('forecast', {input: ForecastIn, output: ForecastOut})
async forecast(input: z.infer<typeof ForecastIn>) { ... }

A TypedPropertyDescriptor constraint on slot 0 forces the method's first parameter to satisfy z.infer of the declared schemas. A mismatch errors at the decoration line with the property difference surfaced precisely. Without this, the implementation can silently drift from the spec and the agent only finds out at request time.

Replaced: per-parameter @param.path / @requestBody / @response / @arg decorators that required the agent to keep three declarations in sync (decorator, parameter type, schema).

Compile-time output enforcement on @tool

@tool('forecast', {input: ForecastIn, output: ForecastOut})
async forecast(input: z.infer<typeof ForecastIn>) {
  return {wrong: 'shape'};
  //     ^^^^^^^^^^^^^^^ TS error at @tool line: not assignable to z.infer<ForecastOut>
}

The decorator's generic R constrains the method's return type. Runtime validation belt-and-suspenders the same check at invocation. The MCP SDK is given outputSchema so structured-content clients consume the typed payload directly.

URL placeholder ↔ path schema check at app.start()

@get('/users/{id}', {path: z.object({userId: z.string()}), response: User})
//          ^^                       ^^^^^^

Throws at startup with Bad.getOne @get('/users/{id}'): path placeholders don't match the path schema — URL has {id} but schema doesn't; schema has [userId] but URL doesn't. Without this, the route silently 422s on every request — exactly the class of mistake agents make often and notice late.

Centralized dispatch with subclassable hooks

RestServer.dispatch / sendResult / sendError are protected and overridable. The standard LB4 sequence pattern (findRoute → parseParams → invoke → send → reject as a five-action DI pipeline) is gone, but the most common use cases (response envelopes, custom error contracts, request-scoped tracing) are covered by single-method overrides:

class EnvelopeRestServer extends RestServer {
  protected override sendResult(res, result, status) {
    res.status(status).json({ok: true, data: result});
  }
}

This is the explicit escape hatch for the "I want to wrap responses uniformly" class of need that previously required reaching for the full sequence pattern.

Middleware chain wired into RestServer

RestApplication extends MiddlewareMixin(Application). RestServer.start() mounts toExpressMiddleware(this.context) before route handlers. Users register cross-cutting concerns via app.middleware(fn) / app.expressMiddleware(factory); middleware runs through the framework's chain and can short-circuit (CORS preflights, rate limit rejections, probes) before reaching the dispatcher.

CORS as a typed config knob

app.configure('servers.RestServer').to({
  cors: {origin: 'https://example.com', credentials: true},
});

One configuration field, a thin wrapper around the cors package, mounted globally in the server constructor. The previous "CORS is a non-goal" framing was misleading — it was unwritten sugar, not an architectural omission.

Honest limits

The thesis is not a free lunch. Where the framework's bet is weaker:

Decorator typing is the binding constraint

Every advance in the framework's agent-friendliness has been a fight against TS's classic decorator type system. The TypedPropertyDescriptor invariance issue alone forced two different workarounds (constrained generic R for input, conditional RouteDescriptor<O, R> for slot-0). The framework cannot get materially more typesafe than the underlying TS decorator implementation allows. Migration to stage-3 decorators may eventually loosen this; we do not currently plan it.

OpenAPI from Zod is correct but not always pretty

z.toJSONSchema produces draft-2020-12 output that satisfies OpenAPI 3.1's dialect. For simple object schemas the output is clean. For Zod features like discriminated unions with branded predicates, recursive types, and complex refinements, the emitted JSON Schema is technically valid but unwieldy. Downstream agents consuming these specs can struggle. We do not currently post-process for readability.

Boundary coherence requires adoption of the pattern

A user who escapes via restServer.expressApp.use(...) for routing, or writes a custom validator in the route handler body, exits the boundary-coherence property for that route. The framework cannot enforce its own use; it can only make the in-pattern path the easy one. Documented escape hatches (CORS option, subclassable dispatch) are the explicit support for "I need to step outside the pattern, here is the supported way." Ad-hoc bypass via the express app is supported but unblessed.

The crossover threshold matters

For genuinely small services (a handful of routes, no plugin surface, no multi-team consumption, no AI tool exposure), the framework is overhead the agent has to maintain. The break-even point is "your app is big enough that boundary coherence costs more to maintain by hand than it costs in framework conventions." Below that line, Hono + zValidator is simpler and the agent does fine. Above that line, the framework's bet pays back.

Not OpenAPI-first in the canonical sense

A pure spec-first workflow writes openapi.yaml standalone, then generates code stubs from it. We do the inverse: write decorators, emit OpenAPI. This means:

For most codebases this is the right trade. For codebases where the API spec is the source of truth and many teams build against it, the inversion would matter.

Design decisions this thesis informs

Whenever the framework gains a feature or absorbs an external pattern, the question to ask is: does this preserve or degrade boundary coherence?

Examples of decisions the thesis has informed (or would inform):

Decision Preserves coherence?
Replacing @param/@requestBody/@response with verb-decorator options Yes (one source of truth)
Adding @arg per-parameter decoration to MCP @tool No (two declarations) — reverted
Adding output: to @tool with typed return constraint Yes (return type derives from schema)
Adding LB4-style sequences/actions with DI-bound pipeline steps No (introduces new abstraction layer that doesn't share artifacts) — kept out
Adding CORS via RestServerConfig.cors Neutral (orthogonal to the schema artifact) — adopted
Adding subclassable dispatch / sendResult / sendError Yes (preserves spec, lets users override behavior) — adopted
Adding a separate openapi.yaml spec authoring step No (introduces a second source of truth) — kept out

The thesis is not "every new feature must touch the Zod schema." It is "no new feature should create a second source of truth that the agent has to keep in sync with the Zod schema."

Open questions

  1. Tool/route filtering for large specs. A 200-tool MCP server or 200-route REST service produces a manifest no single LLM call can hold. Should the inspector/openapi expose ?include=... or ?tag=... filters so agents can fetch scoped subsets?
  2. JSON Schema readability. Zod's emitted schemas are sometimes ugly. Worth post-processing for cleaner downstream consumption?
  3. OpenAPI-first inversion. If demand emerges from cross-team consumers, would we add lb4-from-openapi codegen? At what point does maintaining two source-of-truth artifacts become acceptable?
  4. MCP HTTP/SSE transport. Stdio works today. HTTP is the obvious next addition once the SDK transport is wired in. Should the same RouteOptions shape extend to MCP HTTP routes for symmetry?
  5. Per-tenant or per-version OpenAPI emission. Multi-tenant SaaS deployments may want versioned or tenant-scoped specs. Does the framework grow that, or do users build it on top via the existing enhancer extension point?

References within this codebase

Glossary