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.
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.
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:
Checklist
- Tool
executefunctions usectx.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
buildToolscontract reference - AI UX extras — follow-up chips, panel state, and HITL approval patterns