Skip to Content
TutorialsAI Series2. AI-Assisted Form Fill

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_item tool that the LLM uses to return structured field values without touching the database
  • A server action that persists the confirmed record
  • A /tasks/new page 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/chat route and tools.ts are in place
  • A tasks collection in DaaS with at least title, priority, and due_date fields

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

  1. Navigate to http://localhost:3000/tasks/new
  2. Type a description: “Fix the login bug, high priority, due next Friday”
  3. Click Fill with AI (or press Enter)
  4. The assistant calls list_collections then draft_item — watch the status indicator while it streams
  5. Form fields populate with the suggested values
  6. Edit any field, then click Save task
  7. You are redirected to /tasks with the new record visible in the list

New task form showing the AI description panel above and the form fields populated with AI-suggested values ready to review

Troubleshooting

SymptomFix
Fields don’t populate after AI respondsLog 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 namesEnsure list_collections is being called first; tighten the system prompt to say “only use field names from list_collections”
401 on SaveThe user must be signed in — getAuthHeaders() pulls from the active Supabase session
Due date field stays emptyThe 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 collection prop and loading its fields dynamically from list_collections
  • Add a update_item tool and an Edit with AI button on existing record pages
  • Gate createTaskAction with 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
Last updated on