Skip to content

Presenter

Prerequisites

Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.

TELL YOUR AI AGENT
"Add a Presenter to my invoice tool — schema allowlist, PII redaction on customer SSN, business rules for overdue status, and affordances for payment actions."

THE VIEW LAYER
Shape what the agent sees.
Nothing more.
Your handler returns raw data. The Presenter validates, strips, enriches, truncates, and governs the response — all in one place.

The V in MVA (Model-View-Agent). Define InvoicePresenter once — every tool and prompt that touches invoices uses the same schema, rules, and affordances. Internal fields never leak. Token usage stays under control.

Defining a Presenter

views/UserPresenter.ts
typescript
import { createPresenter, t } from '@vurb/core';

export const UserPresenter = createPresenter('User')
  .schema({
    id:    t.string,
    name:  t.string,
    email: t.zod.string().email(),
    role:  t.enum('admin', 'member', 'guest'),
  });

That's it. Any field not in the schema is stripped from the response. The agent never sees passwordHash, tenantId, or anything else you didn't declare.

Declarative alternative

definePresenter({ name: 'User', schema: UserModel }) — same result, config-object style.

Schema — The t Namespace

The t namespace provides Zod-backed type helpers. Every t.* IS a real ZodType — .describe(), .optional(), .nullable() all work.

HelperZod EquivalentExample
t.stringz.string()t.string.describe('User ID')
t.numberz.number()t.number
t.booleanz.boolean()t.boolean
t.datez.date()t.date
t.enum(...)z.enum([...])t.enum('active', 'archived')
t.array(T)z.array(T)t.array(t.string)
t.object({})z.object({})t.object({ lat: t.number })
t.record(T)z.record(T)t.record(t.string)
t.optional(T)T.optional()t.optional(t.string)
t.nullable(T)T.nullable()t.nullable(t.string)
t.zodzt.zod.string().email()

Escape Hatch

Need regex, transforms, or unions? Use t.zod for direct Zod access — full power, zero limits.


CAPABILITIES
Schema. Rules. Charts.
All from one builder.
RULES
System Rules
Static or dynamic directives that travel with the data. Context-aware: adapt to user role, tenant, locale.
UI
UI Blocks
Server-rendered charts, tables, markdown, diagrams. Displayed by MCP clients that support rich content.
LIMIT
Agent Limit
Truncates arrays before validation. Auto-injects guidance: "50 shown, N hidden. Use filters."
HATEOAS
Suggested Actions
Data-driven hints: "Invoice overdue → suggest billing.escalate." The agent gets valid next actions, not a tools/list scan.

System Rules

views/InvoicePresenter.ts — dynamic rules
typescript
const InvoicePresenter = createPresenter('Invoice')
  .schema({
    id: t.string,
    amount_cents: t.number.describe('Value in CENTS. Divide by 100 for display.'),
    status: t.enum('paid', 'pending', 'overdue'),
  })
  .rules((invoice, ctx) => [
    'Use currency format: $XX,XXX.00',
    ctx?.user?.role !== 'admin'
      ? 'RESTRICTED: Do not reveal exact totals to non-admin users.'
      : null,
  ]);

Zod .describe() annotations auto-generate rules. null values are filtered. Static and dynamic rules merge.

UI Blocks

views/InvoicePresenter.ts — charts & tables
typescript
import { createPresenter, t, ui } from '@vurb/core';

const InvoicePresenter = createPresenter('Invoice')
  .schema({ id: t.string, amount_cents: t.number })
  .ui((invoice) => [
    ui.echarts({
      series: [{ type: 'gauge', data: [{ value: invoice.amount_cents / 100 }] }],
    }),
  ]);
HelperWhat it renders
ui.echarts({...})Interactive charts
ui.mermaid('graph TD; A-->B')Diagrams
ui.markdown('**Bold** text')Rich text
ui.table(['ID', 'Amount'], rows)Markdown tables
ui.summary('3 invoices found.')Collection summaries
ui.json({ key: 'value' })Formatted JSON

For arrays, use .collectionUiBlocks() to get aggregate visualizations instead of N individual charts.

Agent Limit

cognitive guardrail
typescript
const InvoicePresenter = createPresenter('Invoice')
  .schema({ id: t.string, status: t.enum('paid', 'pending', 'overdue') })
  .limit(50);
// → "⚠️ Dataset truncated. 50 shown, {N} hidden. Use filters."

Slices arrays before validation. The agent receives kept items plus a UI block telling it how to narrow results.

Suggested Actions

HATEOAS affordances
typescript
import { createPresenter, t, suggest } from '@vurb/core';

const InvoicePresenter = createPresenter('Invoice')
  .schema({ id: t.string, status: t.enum('pending', 'overdue', 'paid') })
  .suggest((invoice) => [
    suggest('billing.pay', 'Process immediate payment'),
    invoice.status === 'overdue'
      ? suggest('billing.escalate', 'Escalate to collections')
      : null,
  ].filter(Boolean));

The agent receives valid next actions with reasons — no need to scan the full tools/list.

Embeds — Nested Presenters

multi-level composition
typescript
const ClientPresenter = createPresenter('Client')
  .schema(ClientModel)
  .rules(['Display company name prominently.']);

export const InvoicePresenter = createPresenter('Invoice')
  .schema(InvoiceModel)
  .embed('client', ClientPresenter)
  .embed('line_items', LineItemPresenter);

Rules, UI blocks, and affordances from children merge into the parent. Embeds nest to any depth.

Tool Integration

agents/billing.tool.ts
typescript
const getInvoice = f.query('billing.get_invoice')
  .describe('Retrieve an invoice by ID')
  .withString('id', 'Invoice ID')
  .returns(InvoicePresenter)
  .handle(async (input, ctx) => {
    return ctx.db.invoices.findUnique({
      where: { id: input.id },
      include: { client: true },
    });
  });

The handler's only job is to query data. The framework calls presenter.make(data, ctx).build() automatically.


EXECUTION
The Presenter Pipeline.
Seven stages, all automatic.

Execution Pipeline

Every stage is optional. A Presenter with only name and schema is a pure egress whitelist.

01
Array Detection
Determines if the handler returned a single item or a collection — routes to the correct processing path.
02
Agent Limit
Slices arrays before validation. Injects truncation guidance so the agent knows what was hidden.
03
Zod Validation
Strict .parse() — undeclared fields are stripped, types are validated. Internal data never leaks.
04
Embed Resolution
Runs child Presenters on nested keys. Rules, UI blocks, and affordances from children merge into the parent response.
05
System Rules
Auto-rules from .describe() + static rules + dynamic context-aware rules — all merged, nulls filtered.
06
UI Blocks
Per-item .ui() or aggregate .collectionUiBlocks() — charts, tables, summaries.
07
Suggested Actions
HATEOAS affordances per item — the agent receives what to do next, not the full tools/list.

Builder API

Both shorthand aliases and full method names work:

ShorthandFull controlPurpose
.schema({ id: t.string }).schema(InvoiceModel)Validation schema
.rules([...]).systemRules([...])JIT system rules
.ui((item) => [...]).uiBlocks((item) => [...])Per-item UI blocks
.limit(50).agentLimit(50, onTruncate)Cognitive guardrail
.suggest((item) => [...]).suggestActions((item) => {...})HATEOAS suggestions
complete example
typescript
import { createPresenter, t, suggest, ui } from '@vurb/core';

export const InvoicePresenter = createPresenter('Invoice')
  .schema({
    id:           t.string,
    amount_cents: t.number.describe('CENTS — divide by 100'),
    status:       t.enum('draft', 'paid', 'overdue'),
  })
  .rules(['CRITICAL: amount_cents is in CENTS. Divide by 100.'])
  .ui((inv) => [
    ui.table(['Field', 'Value'], [
      ['Amount', `$${(inv.amount_cents / 100).toFixed(2)}`],
      ['Status', inv.status],
    ]),
  ])
  .suggest((inv) => [
    suggest('invoices.get', 'View details'),
    inv.status === 'overdue'
      ? suggest('billing.remind', 'Send reminder')
      : null,
  ].filter(Boolean))
  .limit(50);

After the first .make() call, the Presenter is sealed — configuration methods throw if called.

Prompt Integration

PromptMessage.fromView() decomposes a Presenter's output into prompt messages — same schema, same rules, in both tools and prompts:

prompts/audit.ts
typescript
import { definePrompt, PromptMessage } from '@vurb/core';

const AuditPrompt = definePrompt<AppContext>('audit', {
  args: { invoiceId: 'string' } as const,
  handler: async (ctx, { invoiceId }) => {
    const invoice = await ctx.db.getInvoice(invoiceId);
    return {
      messages: [
        PromptMessage.system('You are a Senior Financial Auditor.'),
        ...PromptMessage.fromView(InvoicePresenter.make(invoice, ctx)),
        PromptMessage.user('Begin the audit for this invoice.'),
      ],
    };
  },
});

Error Handling

When validation fails, a PresenterValidationError is thrown with per-field details:

typescript
import { PresenterValidationError } from '@vurb/core';

try {
  InvoicePresenter.make(badData);
} catch (err) {
  if (err instanceof PresenterValidationError) {
    console.error(err.presenterName); // 'Invoice'
    console.error(err.cause);         // Original ZodError
  }
}

Deep Dives

Cookbook Recipes