Skip to content

Error Handling

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 self-healing error handling to my billing tool — if the invoice is not found, return a recovery path pointing to billing.list_invoices."

SELF-HEALING ERRORS
Errors that fix themselves.
Not just "Not found".
A generic Not found leaves the LLM guessing. Vurb errors carry structured recovery paths — the agent reads <recovery> and self-corrects instantly.
RAW MCP — THE AI GIVES UP
text
AI: "I encountered an error.
     The project was not found." ← gives up
VURB — THE AI SELF-HEALS
text
AI reads: <recovery>Call projects.list first</recovery>
AI: → calls projects.list → finds correct ID
   → retries successfully ✓

error() — Simple Errors

For straightforward failures:

typescript
import { initVurb, error, success } from '@vurb/core';

const f = initVurb<AppContext>();

export const getProject = f.query('projects.get')
  .describe('Get a project by ID')
  .withString('id', 'Project ID')
  .handle(async (input, ctx) => {
    const project = await ctx.db.projects.findUnique({ where: { id: input.id } });
    if (!project) return error(`Project "${input.id}" not found`);
    return success(project);
  });

This works, but the AI only sees text — no recovery path. For guidance, use toolError() or f.error().

required() — Missing Parameters

Tells the agent exactly which parameter to provide:

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

.handle(async (input, ctx) => {
  if (!input.workspace_id) return required('workspace_id');
  // ...
})
xml
<tool_error code="MISSING_REQUIRED_FIELD">
  <message>Required field "workspace_id" is missing.</message>
  <recovery>Provide the "workspace_id" parameter and retry.</recovery>
</tool_error>

toolError() — Self-Healing Errors

Rich error envelope with everything the AI needs to self-correct:

tools/billing/get.ts
typescript
export const getInvoice = f.query('billing.get_invoice')
  .describe('Get an invoice by its ID')
  .withString('id', 'Invoice ID')
  .handle(async (input, ctx) => {
    const invoice = await ctx.db.invoices.findUnique({
      where: { id: input.id },
    });

    if (!invoice) {
      return toolError('InvoiceNotFound', {
        message: `Invoice "${input.id}" does not exist.`,
        suggestion: 'Call billing.list_invoices first to find valid IDs.',
        availableActions: ['billing.list_invoices'],
      });
    }

    return success(invoice);
  });

The agent reads <available_actions> and calls billing.list_invoices instead of retrying with the same invalid ID.

TIP

Use domain-specific codes (InvoiceNotFound, AlreadyPaid) instead of generic ones. They make error logs self-documenting.

ErrorBuilder — Fluent Error Chain

For maximum readability, use the fluent f.error():

tools/billing/charge.ts
typescript
export const chargeInvoice = f.mutation('billing.charge')
  .describe('Process a payment for an invoice')
  .withString('invoice_id', 'Invoice ID')
  .withNumber('amount', 'Payment amount in cents')
  .handle(async (input, ctx) => {
    const invoice = await ctx.db.invoices.findUnique({
      where: { id: input.invoice_id },
    });

    if (!invoice) {
      return f.error('InvoiceNotFound', `Invoice "${input.invoice_id}" not found`)
        .suggest('List invoices first, then retry with a valid ID.')
        .actions('billing.list_invoices');
    }

    if (invoice.status === 'paid') {
      return f.error('AlreadyPaid', `Invoice "${input.invoice_id}" is already settled`)
        .suggest('No action needed. The invoice is settled.')
        .warning();   // ← non-fatal advisory
    }

    if (input.amount > invoice.amount_cents) {
      return f.error('OverPayment', `Amount ${input.amount} exceeds total ${invoice.amount_cents}`)
        .suggest(`Use amount: ${invoice.amount_cents} for full payment.`)
        .details({ invoiceTotal: invoice.amount_cents, attempted: input.amount });
    }

    await ctx.db.payments.create({
      data: { invoiceId: input.invoice_id, amount: input.amount },
    });
    return { status: 'charged', amount: input.amount };
  });

ErrorBuilder Methods

MethodPurpose
.suggest(text)Recovery instruction for the LLM agent
.actions(...names)Tool names the agent should try instead
.warning()Non-fatal advisory (isError: false)
.critical()System-level failure requiring escalation
.severity(level)'error' (default), 'warning', or 'critical'
.details(data)Structured metadata (Record<string, string>)
.retryAfter(seconds)Suggest delay for transient errors

Architect's Check

When your AI agent generates error handlers, verify that every NOT_FOUND error includes an availableActions array or .actions() call. Without recovery paths, the agent falls back to "I encountered an error" — the worst possible UX.

Severity Levels

typescript
// Warning — non-fatal advisory (isError: false)
return f.error('DEPRECATED', 'This endpoint is deprecated')
  .suggest('Use billing.invoices_v2 instead.')
  .actions('billing.invoices_v2')
  .warning();

// Critical — system failure requiring escalation
return f.error('INTERNAL_ERROR', 'Database connection pool exhausted')
  .suggest('Retry after 30 seconds or contact support.')
  .retryAfter(30)
  .critical();

Automatic Validation Errors

Invalid Zod arguments auto-generate per-field corrections — no code needed:

xml
<validation_error action="users/create">
  <field name="email">Invalid email. You sent: 'bad-email'. Expected: a valid email address.</field>
  <field name="role">Invalid enum value. Expected 'admin' | 'user', received 'superadmin'.</field>
  <recovery>Fix the fields above and call the tool again.</recovery>
</validation_error>

Automatic Routing Errors

Missing or misspelled discriminators produce structured corrections:

xml
<tool_error code="UNKNOWN_ACTION">
  <message>The action "destory" does not exist.</message>
  <available_actions>list, create, delete</available_actions>
  <recovery>Choose a valid action from available_actions.</recovery>
</tool_error>

Composing Errors with Result

For multi-step operations, use the Result monad:

typescript
import { succeed, fail, error, success, type Result } from '@vurb/core';

function findUser(db: Database, id: string): Result<User> {
  const user = db.users.get(id);
  return user ? succeed(user) : fail(error(`User "${id}" not found`));
}

.handle(async (input, ctx) => {
  const user = findUser(ctx.db, input.user_id);
  if (!user.ok) return user.response;

  const authorized = checkPermission(user.value, 'delete');
  if (!authorized.ok) return authorized.response;

  await ctx.db.projects.delete({ where: { id: input.project_id } });
  return success('Deleted');
})

The Error Protocol

Error TypeSourceRoot ElementTrigger
error()Handler<tool_error>Generic failures
required()Handler<tool_error code="MISSING_REQUIRED_FIELD">Missing arguments
toolError()Handler<tool_error code="...">Recoverable business errors
f.error()Handler<tool_error code="...">Fluent builder chain
ValidationAutomatic<validation_error>Invalid arguments
RoutingAutomatic<tool_error code="MISSING_DISCRIMINATOR">Bad discriminator

All user-controlled data is XML-escaped automatically.


Next Steps