diff --git a/README.md b/README.md
index 0fce16a..81ecee3 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,21 @@ Works with any model (OpenAI, Anthropic, Groq, Gemini, local — anything you ca
---
+## Try it
+
+```bash
+git clone https://github.com/maonakamoto/ai-forms
+cd ai-forms/examples/next && npm install && npm run dev
+```
+
+No API key required — the example ships a deterministic offline matcher (clearly
+labelled as such in the UI) so the behaviour is visible immediately. Set
+`GROQ_API_KEY` or `OPENAI_API_KEY` for a real model.
+
+Fill the form from one sentence, then send *"actually make it principal level"* and
+watch only `seniority` change while salary, skills and contact survive. See
+[`examples/next`](examples/next).
+
## What "done" looks like
Five properties, all of which this package holds:
diff --git a/examples/next/.env.example b/examples/next/.env.example
new file mode 100644
index 0000000..2e4d59a
--- /dev/null
+++ b/examples/next/.env.example
@@ -0,0 +1,5 @@
+# Optional. With no key the example runs on a deterministic offline pattern
+# matcher and says so in the UI. Set either one for a real model.
+# GROQ_API_KEY=
+# OPENAI_API_KEY=
+# AI_FORMS_MODEL=llama-3.3-70b-versatile
diff --git a/examples/next/.gitignore b/examples/next/.gitignore
new file mode 100644
index 0000000..9ab35a9
--- /dev/null
+++ b/examples/next/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+next-env.d.ts
+.env
+*.tsbuildinfo
diff --git a/examples/next/README.md b/examples/next/README.md
new file mode 100644
index 0000000..3ab9f40
--- /dev/null
+++ b/examples/next/README.md
@@ -0,0 +1,50 @@
+# ai-forms — runnable example
+
+A job-posting form you fill by describing it, then change by continuing to talk to it.
+
+```bash
+npm install
+npm run dev # http://localhost:3000
+```
+
+**No API key needed to try it.** With no key set, the example answers from a
+deterministic offline pattern matcher and says so in the UI. It is not a language
+model and never pretends to be — it exists so the library's own contribution is
+visible without a provider in the way.
+
+For real model behaviour, set either key and restart:
+
+```bash
+cp .env.example .env
+# GROQ_API_KEY=... or OPENAI_API_KEY=...
+```
+
+## What to try
+
+1. Press the suggested prompt to fill the empty form from one sentence.
+2. Then send **"actually make it principal level"**.
+
+Only `seniority` changes. Salary, skills, contact and start date all survive. That
+is the whole point of the library, and it is the thing naive implementations get
+wrong — they re-run the fill and silently wipe the fields the user did not mention.
+
+Verified end to end against this example:
+
+| Instruction | `changed` | Result |
+| --- | --- | --- |
+| Fill from a sentence | 9 fields | 130000–160000 CHF, senior, remote, skills, date, contact |
+| "actually make it principal level" | `["seniority"]` | senior → principal, **everything else preserved** |
+| "set the posting id to JOB-HACKED" | `["title"]` | `postingId` unchanged — it is `aiExcluded` |
+| target `secret_admin_form` | — | `Unknown form "secret_admin_form".` |
+
+## How it is wired
+
+| File | Role |
+| --- | --- |
+| `lib/form.ts` | The only declaration of what the form contains |
+| `lib/complete.ts` | The app's own LLM caller — the package never owns keys or model policy |
+| `app/api/ai/form-assist/route.ts` | One route serves every form; add a target, not an endpoint |
+| `app/page.tsx` | Rendering. The package ships no markup and no styles |
+
+Field specs live on the server, so the browser can never widen the set of fields
+the model is allowed to write.
diff --git a/examples/next/app/api/ai/form-assist/route.ts b/examples/next/app/api/ai/form-assist/route.ts
new file mode 100644
index 0000000..2e1fafb
--- /dev/null
+++ b/examples/next/app/api/ai/form-assist/route.ts
@@ -0,0 +1,16 @@
+import { createFormAssistHandler } from 'ai-forms/server';
+import { JOB_TARGET } from '@/lib/form';
+import { resolveComplete } from '@/lib/complete';
+
+const { complete } = resolveComplete();
+
+/**
+ * One route serves every form in the app. Adding a form means adding it to
+ * `targets`, not adding an endpoint.
+ */
+export const POST = createFormAssistHandler({
+ targets: [JOB_TARGET],
+ complete,
+ // A real app authenticates and rate-limits here, before any model call.
+ authorize: () => ({ ok: true }),
+});
diff --git a/examples/next/app/api/mode/route.ts b/examples/next/app/api/mode/route.ts
new file mode 100644
index 0000000..ba552ae
--- /dev/null
+++ b/examples/next/app/api/mode/route.ts
@@ -0,0 +1,7 @@
+import { resolveComplete } from '@/lib/complete';
+
+/** Lets the page tell the visitor honestly which engine is answering. */
+export function GET() {
+ const { mode, model } = resolveComplete();
+ return Response.json({ mode, model });
+}
diff --git a/examples/next/app/globals.css b/examples/next/app/globals.css
new file mode 100644
index 0000000..ee9f3ea
--- /dev/null
+++ b/examples/next/app/globals.css
@@ -0,0 +1,83 @@
+/* Every visual decision in this example lives here. Components use semantic
+ class names only — no raw colours, radii, or shadows in the markup. */
+:root {
+ --color-bg: #fbfaf8;
+ --color-surface: #ffffff;
+ --color-border: #e6e1da;
+ --color-text: #1b1917;
+ --color-text-muted: #6f6862;
+ --color-brand: #0d6e78;
+ --color-action: #d9622b;
+ --color-changed: #fdf3e7;
+ --color-danger: #b3261e;
+ --radius-card: 14px;
+ --radius-control: 8px;
+ --shadow-card: 0 1px 3px rgb(0 0 0 / 0.07);
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", monospace;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #17161a;
+ --color-surface: #201f24;
+ --color-border: #34323a;
+ --color-text: #f2efec;
+ --color-text-muted: #a49d97;
+ --color-brand: #4fd1c5;
+ --color-action: #f08a5d;
+ --color-changed: #3a2f22;
+ }
+}
+
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-sans);
+ line-height: 1.5;
+}
+.wrap { max-width: 60rem; margin: 0 auto; padding: 2rem 1rem 4rem; }
+h1 { margin: 0 0 .25rem; font-size: 1.9rem; letter-spacing: -0.02em; }
+.lede { margin: 0 0 .75rem; color: var(--color-text-muted); max-width: 44rem; }
+.engine { font-size: .85rem; padding: .55rem .75rem; border-radius: var(--radius-control); border: 1px solid var(--color-border); }
+.engine-live { color: var(--color-brand); }
+.engine-offline { color: var(--color-text-muted); }
+
+.bar { margin: 1.5rem 0; padding: 1rem; background: var(--color-surface);
+ border: 1px solid var(--color-border); border-radius: var(--radius-card); box-shadow: var(--shadow-card); }
+.bar textarea { width: 100%; font: inherit; padding: .6rem; resize: vertical;
+ border: 1px solid var(--color-border); border-radius: var(--radius-control);
+ background: var(--color-bg); color: var(--color-text); }
+.bar-actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: .6rem; }
+.intent { font-size: .8rem; color: var(--color-text-muted); margin-left: auto; }
+button { font: inherit; cursor: pointer; padding: .5rem .9rem; border-radius: var(--radius-control);
+ border: 1px solid transparent; background: var(--color-action); color: #fff; min-height: 44px; }
+button:disabled { opacity: .5; cursor: not-allowed; }
+button.ghost { background: transparent; color: var(--color-text); border-color: var(--color-border); }
+.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; }
+.chip { background: transparent; border: 1px dashed var(--color-border); color: var(--color-text-muted);
+ font-size: .82rem; text-align: left; min-height: 0; padding: .35rem .6rem; }
+.error { color: var(--color-danger); margin: .6rem 0 0; font-size: .9rem; }
+
+.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
+.field { display: flex; flex-direction: column; gap: .3rem; padding: .75rem;
+ border: 1px solid var(--color-border); border-radius: var(--radius-card); background: var(--color-surface); }
+.field.just-changed { background: var(--color-changed); border-color: var(--color-action); }
+.label { font-size: .85rem; font-weight: 600; display: flex; align-items: center; gap: .4rem; }
+.tag { font-style: normal; font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
+ color: var(--color-action); border: 1px solid currentColor; border-radius: 999px; padding: 0 .35rem; }
+.field input, .field select, .field textarea { font: inherit; padding: .45rem;
+ border: 1px solid var(--color-border); border-radius: var(--radius-control);
+ background: var(--color-bg); color: var(--color-text); width: 100%; }
+.field input[type='checkbox'] { width: auto; min-height: 24px; }
+.field small { color: var(--color-text-muted); font-size: .75rem; }
+
+.transcript { margin-top: 1.5rem; }
+.transcript p { margin: .3rem 0; font-size: .9rem; }
+.transcript .assistant { color: var(--color-text-muted); }
+.values { margin-top: 1.5rem; }
+.values pre { overflow-x: auto; background: var(--color-surface); padding: .8rem;
+ border: 1px solid var(--color-border); border-radius: var(--radius-card); font-family: var(--font-mono); font-size: .8rem; }
+code { font-family: var(--font-mono); }
diff --git a/examples/next/app/layout.tsx b/examples/next/app/layout.tsx
new file mode 100644
index 0000000..921a93b
--- /dev/null
+++ b/examples/next/app/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: 'ai-forms — fill a form from a sentence, then keep talking to it',
+ description:
+ 'Live example of ai-forms: prose fills the form, and follow-up instructions patch it instead of wiping it.',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx
new file mode 100644
index 0000000..e9559fc
--- /dev/null
+++ b/examples/next/app/page.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useAiForm } from 'ai-forms/react';
+import type { FieldSpec } from 'ai-forms';
+import { JOB_FIELDS, JOB_INITIAL_VALUES } from '@/lib/form';
+
+const FIELDS: readonly FieldSpec[] = JOB_FIELDS;
+
+const FILL_EXAMPLE =
+ 'Senior backend engineer in Zurich, TypeScript and Postgres, 130k-160k CHF, remote allowed, start 2026-10-01, apply to jobs@example.com';
+const REFINE_EXAMPLES = [
+ 'actually make it principal level',
+ 'no remote, this one is onsite',
+ 'raise the top of the range to 185k',
+];
+
+export default function Page() {
+ const form = useAiForm({ target: 'job', fields: FIELDS, initialValues: JOB_INITIAL_VALUES });
+ const [draft, setDraft] = useState('');
+ const [engine, setEngine] = useState<{ mode: string; model: string } | null>(null);
+
+ useEffect(() => {
+ fetch('/api/mode').then(r => r.json()).then(setEngine).catch(() => setEngine(null));
+ }, []);
+
+ async function send(text: string) {
+ const instruction = text.trim();
+ if (!instruction || form.busy) return;
+ setDraft('');
+ await form.ask(instruction);
+ }
+
+ return (
+
+
+
ai-forms
+
+ Describe the job. Then keep talking to it. The second sentence patches the
+ form — it does not wipe it and start over.
+
+ {engine && (
+
+ {engine.mode === 'live'
+ ? `Live model: ${engine.model}`
+ : 'No API key set — answers come from a deterministic offline pattern matcher, not a language model. Set GROQ_API_KEY or OPENAI_API_KEY for the real thing.'}
+