---
title: "Build actions"
description: "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.

> **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`.

```ts title="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.

```ts title="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:

```tsx title="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.

```ts
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.

```ts
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`:

```tsx
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.

```tsx
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.

```ts
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:

> **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](/docs/guides/action-templates) — the full primitive catalog (ActionButton, ActionForm, EntityCard, etc.)
- [Models & tools](/docs/guides/models-and-tools) — the `buildTools` contract reference
- [AI UX extras](/docs/guides/ai-ux) — follow-up chips, panel state, and HITL approval patterns
