The MVA Pattern
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
Model-View-Agent (MVA) replaces the human-centric View of MVC with a Presenter — a deterministic perception layer that tells the agent how to interpret, display, and act on domain data. For the full reference, see the MVA Architecture Section.
Why MVC Fails for Agents
MVC wasn't built for agents. The Model-View-Controller pattern works when a human reads the screen and applies their own judgment. An AI agent doesn't have that luxury — it acts on exactly what you give it. If your tool returns raw data with no context, the agent fills in the blanks. That's where hallucination starts.
When a tool returns { "amount_cents": 45000, "status": "pending" }, the agent guesses: cents or dollars? Offer a payment action? What visualization? Every guess is a potential hallucination.
Context Starvation — data without rules means 45000 displays as dollars. Action Blindness — no affordances means hallucinated tool names. Perception Inconsistency — same entity presented differently by different tools means contradictory behavior.
The Solution: MVA
Model View Agent
───── ──── ─────
Domain Data → Presenter → LLM/AI
(Zod Schema) (Rules + (Claude,
UI Blocks + GPT, etc.)
Affordances)The Presenter is domain-level, not tool-level. Define InvoicePresenter once — every tool that returns invoices uses the same Presenter. The agent always perceives invoices identically. Tools can be hand-written with the Fluent API, or auto-generated from an OpenAPI spec (@vurb/openapi-gen) or a Prisma schema (@vurb/prisma-gen) — the Presenter layer works identically regardless of how the Model layer is authored.
The Presenter
Three APIs produce the same result: createPresenter('Name').schema(s).rules(r) (fluent), definePresenter({}) (declarative), f.presenter({}) (context-aware). Each method has a shorthand (.rules(), .ui(), .limit(), .suggest()) and a full-control alias (.systemRules(), .uiBlocks(), .agentLimit(), .suggestActions()).
Schema Validation
The Zod schema is a security boundary. Only declared fields pass through:
import { createPresenter, t } from '@vurb/core';
export const InvoicePresenter = createPresenter('Invoice')
.schema({
id: t.string,
amount_cents: t.number.describe('Amount in cents — divide by 100 for display'),
status: t.enum('paid', 'pending', 'overdue'),
});.describe() annotations auto-extract as system rules. Fields like password_hash or tenant_id are never in the schema, so they never reach the agent.
System Rules
Rules travel with the data, not in a global system prompt:
export const InvoicePresenter = definePresenter({
name: 'Invoice',
schema: invoiceSchema,
rules: [
'CRITICAL: amount_cents is in CENTS. Always divide by 100 before display.',
'Use currency format: $XX,XXX.00',
'Use status emojis: ✅ paid, ⏳ pending, 🔴 overdue',
],
});When the agent works with users or orders, invoice rules aren't loaded. This is Context Tree-Shaking.
Context-Aware Rules
Rules receive data and request context. Return null to exclude conditionally:
const InvoicePresenter = createPresenter('Invoice')
.schema(invoiceSchema)
.rules((invoice, ctx) => [
'CRITICAL: amount_cents is in CENTS. Divide by 100.',
ctx?.user?.role !== 'admin'
? 'RESTRICTED: Mask financial totals for non-admin users.'
: null,
`Format dates using ${ctx?.tenant?.locale ?? 'en-US'}.`,
]);UI Blocks
Presenters generate charts and visualizations the agent renders directly:
import { definePresenter, ui } from '@vurb/core';
export const InvoicePresenter = definePresenter({
name: 'Invoice',
schema: invoiceSchema,
ui: (invoice) => [
ui.echarts({
series: [{ type: 'gauge', data: [{ value: invoice.amount_cents / 100 }] }],
}),
],
collectionUi: (invoices) => [
ui.echarts({
xAxis: { data: invoices.map(i => i.id) },
series: [{ type: 'bar', data: invoices.map(i => i.amount_cents / 100) }],
}),
ui.summary(
`${invoices.length} invoices. Total: $${(invoices.reduce((s, i) => s + i.amount_cents, 0) / 100).toLocaleString()}`
),
],
});.ui() fires for single items. .collectionUi() fires for arrays. Auto-detected.
Cognitive Guardrails
.limit() is the shorthand; .agentLimit() gives full control with a custom message:
// Shorthand — auto-generated truncation message
const InvoicePresenter = createPresenter('Invoice')
.schema(invoiceSchema)
.limit(50);
// Full control — custom truncation message
const InvoicePresenter = createPresenter('Invoice')
.schema(invoiceSchema)
.agentLimit(50, (omitted) =>
ui.summary(
`⚠️ Dataset truncated. Showing 50 of ${50 + omitted} invoices. ` +
`Use filters (status, date_range) to narrow results.`
)
);Without this, 10,000 rows dump into the context window. With it, the agent receives 50 rows plus an instruction to refine.
Affordances
.suggest() with the suggest() helper tells the agent what it can do next based on state:
import { createPresenter, suggest } from '@vurb/core';
const InvoicePresenter = createPresenter('Invoice')
.schema(invoiceSchema)
.suggest((invoice) => [
suggest('billing.pay', 'Process immediate payment'),
invoice.status === 'pending'
? suggest('billing.send_reminder', 'Send payment reminder')
: null,
invoice.status === 'overdue'
? suggest('billing.escalate', 'Escalate to collections')
: null,
].filter(Boolean));The agent receives → billing.pay: Process immediate payment as a system hint. No hallucinated tool names, no skipped workflows.
Presenter Composition
.embed() composes Presenters across relationships:
const ClientPresenter = createPresenter('Client')
.schema(clientSchema)
.rules(['Display company name prominently.']);
const InvoicePresenter = createPresenter('Invoice')
.schema(invoiceSchema)
.rules(['amount_cents is in CENTS.'])
.embed('client', ClientPresenter);ClientPresenter's rules and UI blocks merge into the invoice response automatically. Define once — reuse in InvoicePresenter, OrderPresenter, ContractPresenter.
Pipeline Integration
The .returns() method connects a Presenter to a tool. The handler returns raw data; the Presenter does everything else:
import { initVurb } from '@vurb/core';
const f = initVurb<AppContext>();
const getInvoice = f.query('billing.get_invoice')
.describe('Get an invoice by ID')
.withString('invoice_id', 'The exact invoice ID')
.returns(InvoicePresenter)
.handle(async (input, ctx) => {
return await ctx.db.invoices.findUnique({
where: { id: input.invoice_id },
include: { client: true },
});
});The handler (Model) produces raw data. The Presenter (View) shapes perception. The LLM (Agent) acts on structured context.
TIP
The handler can return raw data directly — FluentToolBuilder.handle() auto-wraps non-ToolResponse returns with success(). No need to manually call success() when using .returns().
For one-off responses that don't map to a reusable entity, use response(data).uiBlock(...).systemRules([...]).build() — see the Presenter Guide for the ResponseBuilder API.