Skip to content
mordn
Esc
↑↓navigate↵open⌘Jpreview
On this page

Build actions

Let your chat agent perform real actions in your app — create records, update orders, search data — with copy-paste recipes an AI coding agent can implement in one pass.

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.

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.

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
    },
  }),
});

Recipe 1: Create a record

Let the agent create a support ticket in your app.

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:

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.

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.

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:

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.

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.

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

Pattern When to use Component
toolRenderers Custom JSX for a tool’s output Return any ReactNode
actionRenderers Declarative status card (success/error/pending) ActionResultCard
EntityCarousel List of cards (products, orders, results) Built-in
SummaryCard Key-value summary (cart totals, booking details) Built-in
StatusTracker Timeline (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:

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