Tutorial 2: AI-Assisted Form Fill
In this tutorial you’ll add a Fill with AI button to a form in your generated app. The user describes what they want to create in plain English — for example, “A task called Fix login bug, high priority, due next Friday” — and the assistant drafts the field values. The user reviews the prefilled form and submits directly to DaaS. The AI never writes to the database without confirmation.
By the end you’ll have:
- A
draft_itemtool that the LLM uses to return structured field values without touching the database - A server action that persists the confirmed record
- A
/tasks/newpage with an AI fill panel built on top of your existing Mantine components - End-to-end flow: describe → AI drafts → user reviews → saves
Prerequisites
- Tutorial 1 completed:
/api/chatroute andtools.tsare in place - A
taskscollection in DaaS with at leasttitle,priority, anddue_datefields
Add the draft_item tool
Open app/api/chat/tools.ts and add a draft_item tool below the existing ones. This tool lets the model return structured field suggestions without calling the DaaS API — the user is always the one who decides to save.
// app/api/chat/tools.ts
draft_item: tool({
description:
"Prepare field values for a new item based on the user's description. Return the suggested values — do NOT save to the database.",
inputSchema: z.object({
collection: z.string().describe("The collection slug, e.g. 'tasks'."),
fields: z
.record(z.string(), z.unknown())
.describe("Key-value pairs mapping field names to suggested values."),
}),
execute: async ({ collection, fields }) => {
// No DaaS call — just returns the draft so the UI can populate the form
return { collection, fields, status: "draft" };
},
}),Only use field names returned by list_collections. Add a reminder in the system prompt so the model doesn’t invent field names.
Update the system prompt
Extend the system prompt in app/api/chat/route.ts so the model knows to call list_collections first, then draft_item:
// app/api/chat/route.ts — inside the POST handler, before streamText
const today = new Date().toISOString().slice(0, 10);
// …then pass this as the system string:
system: `You are an assistant embedded in a Buildpad app. Today is ${today}.
When helping a user fill a form:
1. Call list_collections to discover the collection's fields and their types.
2. Call draft_item with your best interpretation of the user's description, using only the field names you found in step 1.
3. After calling draft_item, briefly summarise what you filled in and invite the user to review.
Format all date values as YYYY-MM-DD (e.g. ${today}). Never invent field names. Never call create or write operations — the user will save the form themselves.`,Including today’s date lets the model resolve relative expressions like “next Friday” to an absolute calendar date. The YYYY-MM-DD format instruction ensures the value is compatible with the HTML type="date" input — without it, some models return a full ISO timestamp that the input silently ignores.
Create the save server action
The form’s Save button needs to write to DaaS using the Supabase session JWT. Create a server action so the auth headers stay server-side:
// app/(authenticated)/tasks/new/actions.ts
"use server";
import { getAuthHeaders, getDaasUrl } from "@/lib/api/auth-headers";
export async function createTaskAction(data: Record<string, unknown>) {
const url = `${getDaasUrl()}/api/items/tasks`;
const headers = await getAuthHeaders();
const res = await fetch(url, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(data),
cache: "no-store",
});
if (!res.ok) throw new Error(`DaaS ${res.status}: ${await res.text()}`);
return res.json();
}Build the form page
Create app/(authenticated)/tasks/new/page.tsx. The page has two panels: an AI description panel on top and the editable form below. When the AI calls draft_item, the onFinish callback reads the tool result and populates the form state.
// app/(authenticated)/tasks/new/page.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
Button,
Divider,
Group,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { createTaskAction } from "./actions";
interface TaskFields {
title?: string;
priority?: string;
due_date?: string;
description?: string;
}
export default function NewTaskPage() {
const router = useRouter();
const [prompt, setPrompt] = useState("");
const [fields, setFields] = useState<TaskFields>({});
const [saving, setSaving] = useState(false);
const { sendMessage, status } = useChat({
// No `api` option — useChat posts to /api/chat by default
onFinish: ({ message }) => {
// Find the draft_item tool output in the completed message.
// In ai v6 a tool part has type `tool-<toolName>` and its result
// lives in `output` once `state === "output-available"`.
const draftPart = message.parts?.find(
(p) =>
p.type === "tool-draft_item" &&
(p as any).state === "output-available"
) as any | undefined;
if (draftPart?.output?.fields) {
const raw = draftPart.output.fields as TaskFields;
// Normalize any date string to YYYY-MM-DD for the HTML date input
if (raw.due_date) {
const d = new Date(raw.due_date);
if (!isNaN(d.getTime())) raw.due_date = d.toISOString().slice(0, 10);
}
setFields((prev) => ({ ...prev, ...raw }));
}
},
});
const handleFillWithAI = () => {
if (!prompt.trim()) return;
sendMessage({
text: `Fill a new task form based on this: "${prompt}". The collection is "tasks".`,
});
};
const handleSave = async () => {
setSaving(true);
try {
await createTaskAction(fields);
router.push("/tasks");
} finally {
setSaving(false);
}
};
const isFilling = status === "streaming" || status === "submitted";
return (
<Stack gap="xl" maw={720} mx="auto" py="xl">
<Title order={2}>New Task</Title>
{/* AI Fill Panel */}
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={500} size="sm">
Describe the task
</Text>
<Textarea
placeholder='e.g. "Fix the login bug, high priority, due next Friday"'
value={prompt}
onChange={(e) => setPrompt(e.currentTarget.value)}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleFillWithAI();
}
}}
/>
<Button
leftSection={<IconSparkles size={16} />}
loading={isFilling}
onClick={handleFillWithAI}
variant="light"
w="fit-content"
>
Fill with AI
</Button>
</Stack>
</Paper>
<Divider label="Review & edit" labelPosition="center" />
{/* Form */}
<Stack gap="md">
<TextInput
label="Title"
placeholder="Task title"
value={fields.title ?? ""}
onChange={(e) => setFields((f) => ({ ...f, title: e.currentTarget.value }))}
required
/>
<Select
label="Priority"
data={["low", "medium", "high"]}
value={fields.priority ?? null}
onChange={(v) => setFields((f) => ({ ...f, priority: v ?? undefined }))}
clearable
/>
<TextInput
label="Due date"
type="date"
value={fields.due_date ?? ""}
onChange={(e) => setFields((f) => ({ ...f, due_date: e.currentTarget.value }))}
/>
<Textarea
label="Description"
value={fields.description ?? ""}
onChange={(e) => setFields((f) => ({ ...f, description: e.currentTarget.value }))}
rows={3}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => router.back()}>
Cancel
</Button>
<Button
onClick={handleSave}
loading={saving}
disabled={!fields.title?.trim()}
>
Save task
</Button>
</Group>
</Stack>
</Stack>
);
}Verify it works
- Navigate to
http://localhost:3000/tasks/new - Type a description: “Fix the login bug, high priority, due next Friday”
- Click Fill with AI (or press Enter)
- The assistant calls
list_collectionsthendraft_item— watch the status indicator while it streams - Form fields populate with the suggested values
- Edit any field, then click Save task
- You are redirected to
/taskswith the new record visible in the list

Troubleshooting
| Symptom | Fix |
|---|---|
| Fields don’t populate after AI responds | Log message.parts in onFinish — confirm a tool-draft_item part with state: "output-available" is present, and read its output (not result) |
| Model invents field names | Ensure list_collections is being called first; tighten the system prompt to say “only use field names from list_collections” |
| 401 on Save | The user must be signed in — getAuthHeaders() pulls from the active Supabase session |
| Due date field stays empty | The model returned a non-YYYY-MM-DD date string. Ensure the system prompt includes today’s date and the YYYY-MM-DD instruction — the onFinish normalization handles any remaining format variance |
What’s Next
- Extend to other collections by accepting a
collectionprop and loading its fields dynamically fromlist_collections - Add a
update_itemtool and an Edit with AI button on existing record pages - Gate
createTaskActionwith Zod validation to catch type mismatches before the DaaS request - Continue to Tutorial 3: AI Summaries & Reports to generate natural-language reports from your live collection data