Build actions in your app

Your chat widget's LLM can do more than answer questions — it can perform actions in your product. Create support tickets, update order status, search your database, file booking requests. The agent reasons about what to do, calls a server-side tool, and the widget renders the result as a rich card.

This guide is structured so a developer (or their AI coding agent) can wire up a working action integration in one pass. Each recipe is complete and copy-pasteable.

Security

Every tool runs on your server behind getChatUserId. The LLM never touches your database directly — it calls a tool function you define, which executes with the verified user's identity. Never trust browser-submitted payloads or client-supplied user ids.

The contract

Actions are defined in buildTools inside createChatHandler. Each tool has a description, a Zod input schema, and an execute function. The handler calls the tool when the model decides to, passes the parsed arguments, and returns the result to the model. The widget renders the result via toolRenderers.

code
app/api/chat/[[...chat]]/route.ts
import { createChatHandler } from '@mordn/chat-widget/server';
import { tool } from 'ai';
import { z } from 'zod';
 
export const { GET, POST, DELETE } = createChatHandler({
  getUserId,
  model,
  store,
  buildTools: async (ctx) => ({
    tools: {
      // Define tools here — the LLM calls them, execute runs on your server
      myAction: tool({
        description: 'Describe what this tool does so the model knows when to call it',
        inputSchema: z.object({
          param1: z.string().describe('What this parameter means'),
        }),
        execute: async ({ param1 }) => {
          // Your app logic here — db calls, API calls, etc.
          // ctx.userId is the verified server-side user identity
          return { ok: true, result: `Done: ${param1}` };
        },
      }),
    },
    cleanup: () => {
      // Optional: close connections, release resources
    },
  }),
});

Note

AI SDK v6 uses inputSchema (not parameters). Always use inputSchema with a Zod schema — the model needs the schema to know what arguments to pass.

Recipe 1: Create a record

Let the agent create a support ticket in your app.

code
app/api/chat/[[...chat]]/route.ts
createSupportTicket: tool({
  description: 'Create a support ticket for the current user with a subject and priority',
  inputSchema: z.object({
    subject: z.string().min(5).describe('A short summary of the issue'),
    priority: z.enum(['low', 'medium', 'high']).describe('How urgent this is'),
  }),
  execute: async ({ subject, priority }) => {
    const ticket = await db.ticket.create({
      data: {
        userId: ctx.userId,
        subject,
        priority,
        status: 'open',
      },
    });
    return {
      ok: true,
      ticketId: ticket.id,
      message: `Ticket ${ticket.id} created with ${priority} priority.`,
    };
  },
}),

Render the result in the widget:

code
app/components/Assistant.tsx
import { ChatWidget, type ToolRenderer } from '@mordn/chat-widget';
 
const toolRenderers: Record<string, ToolRenderer> = {
  createSupportTicket: (part) => {
    if (part.state !== 'output-available') return null;
    const output = part.output as { ok?: boolean; ticketId?: string; message?: string };
    if (!output?.ok) return null;
    return (
      <p className="text-sm">
{output.message}{' '}
        <a href={`/tickets/${output.ticketId}`} className="text-primary underline">
          View ticket →
        </a>
      </p>
    );
  },
};
 
<ChatWidget toolRenderers={toolRenderers} />

Recipe 2: Update a record

Let the agent update an order's status.

code
updateOrderStatus: tool({
  description: 'Update the status of an existing order',
  inputSchema: z.object({
    orderId: z.string().describe('The order identifier'),
    status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled'])
      .describe('The new status'),
  }),
  execute: async ({ orderId, status }) => {
    // Verify the order belongs to this user
    const order = await db.order.findFirst({
      where: { id: orderId, userId: ctx.userId },
    });
    if (!order) return { ok: false, error: 'Order not found for this user' };
 
    await db.order.update({
      where: { id: orderId },
      data: { status },
    });
    return { ok: true, orderId, newStatus: status };
  },
}),

Recipe 3: Search your data

Let the agent search your product catalog, knowledge base, or user records.

code
searchProducts: tool({
  description: 'Search the product catalog by name, category, or keyword',
  inputSchema: z.object({
    query: z.string().describe('The search term'),
    limit: z.number().min(1).max(20).default(5).describe('Max results to return'),
  }),
  execute: async ({ query, limit }) => {
    const products = await db.product.findMany({
      where: { name: { contains: query, mode: 'insensitive' } },
      take: limit,
      select: { id: true, name: true, price: true, category: true },
    });
    return { products, count: products.length };
  },
}),

Render search results as swipeable cards using the widget's built-in EntityCarousel:

code
import { ChatWidget, EntityCarousel, type ToolRenderer, type MordnEntityItem } from '@mordn/chat-widget';
 
const toolRenderers: Record<string, ToolRenderer> = {
  searchProducts: (part) => {
    if (part.state !== 'output-available') return null;
    const output = part.output as { products?: Array<{ id: string; name: string; price: number; category: string }> };
    if (!output?.products?.length) return null;
    const items: MordnEntityItem[] = output.products.map((p) => ({
      id: p.id,
      title: p.name,
      price: `$${p.price.toFixed(2)}`,
      badge: p.category,
    }));
    return <EntityCarousel label="Search results" items={items} />;
  },
};

Recipe 4: Action with confirmation

For consequential actions (creating, updating, deleting), use the widget's actionRenderers to show a declarative result card with status + fields.

code
import { ChatWidget, type ActionRenderer } from '@mordn/chat-widget';
 
const actionRenderers: Record<string, ActionRenderer> = {
  createSupportTicket: (part) => {
    if (part.state === 'output-error') {
      return { status: 'error', title: "Couldn't create ticket", note: part.errorText };
    }
    if (part.state !== 'output-available') {
      return { status: 'pending', title: 'Creating ticket…' };
    }
    const output = part.output as { ok: boolean; ticketId?: string; message?: string };
    if (!output.ok) return { status: 'partial', title: 'Ticket not created', note: 'Something went wrong.' };
    return {
      status: 'success',
      title: 'Support ticket created',
      fields: [
        { label: 'Ticket ID', value: output.ticketId },
        { label: 'Status', value: 'Open' },
      ],
      note: output.message,
    };
  },
};
 
<ChatWidget actionRenderers={actionRenderers} />

Recipe 5: Multi-step workflow

Tools can chain — the model calls one, reads the result, and decides what to do next. Define multiple tools and let the model compose them.

code
buildTools: async (ctx) => ({
  tools: {
    // Step 1: search for a user's orders
    searchOrders: tool({
      description: 'Find orders for the current user, optionally filtered by status',
      inputSchema: z.object({
        status: z.enum(['all', 'pending', 'shipped', 'delivered']).default('all'),
      }),
      execute: async ({ status }) => {
        const orders = await db.order.findMany({
          where: { userId: ctx.userId, ...(status !== 'all' && { status }) },
        });
        return { orders };
      },
    }),
    // Step 2: get details for a specific order
    getOrderDetails: tool({
      description: 'Get full details for a specific order including line items',
      inputSchema: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => {
        const order = await db.order.findFirst({
          where: { id: orderId, userId: ctx.userId },
          include: { items: true },
        });
        if (!order) return { ok: false, error: 'Order not found' };
        return { ok: true, order };
      },
    }),
    // Step 3: request a refund (capture, not execute — human reviews)
    requestRefund: tool({
      description: 'Submit a refund request for an order. A human will review it.',
      inputSchema: z.object({
        orderId: z.string(),
        reason: z.string().min(10).describe('Why the refund is being requested'),
      }),
      execute: async ({ orderId, reason }) => {
        const refund = await db.refundRequest.create({
          data: { orderId, userId: ctx.userId, reason, status: 'pending_review' },
        });
        return { ok: true, refundId: refund.id, status: 'pending_review' };
      },
    }),
  },
}),

The model can now: search orders → pick one → get details → request a refund — all in one conversation, each step visible to the user.

Rendering patterns

PatternWhen to useComponent
toolRenderersCustom JSX for a tool's outputReturn any ReactNode
actionRenderersDeclarative status card (success/error/pending)ActionResultCard
EntityCarouselList of cards (products, orders, results)Built-in
SummaryCardKey-value summary (cart totals, booking details)Built-in
StatusTrackerTimeline (order status, setup progress)Built-in

Return null from a toolRenderer to fall through to the default tool row.

Safety checklist

Before shipping an action integration:

Checklist

  • Tool execute functions use ctx.userId (server-verified), never client input
  • Input schemas validate all parameters with Zod
  • Database queries scope to the verified user (where: { userId: ctx.userId })
  • Consequential actions (create/update/delete) show a result card via actionRenderers
  • Error states are handled (output-error → error card, not a crash)
  • No secrets, API keys, or sensitive data in tool descriptions or return values

Next steps

  • Action templates — the full primitive catalog (ActionButton, ActionForm, EntityCard, etc.)
  • Models & tools — the buildTools contract reference
  • AI UX extras — follow-up chips, panel state, and HITL approval patterns