bitbaum Start a project
Package · MIT

ai-forms

Fill a form from prose, then refine it in conversation. Headless, so every app keeps its own look.

Why it exists

People do not want to meet a form. They want to say what they need. Each product was about to build its own 'paste a paragraph, get fields' box, with its own prompt and its own way of being wrong.

How it works

A headless core turns prose into the fields a schema already allows, then lets the person correct them in conversation. A React hook and a route factory are optional. The form on the page stays the product's own.

Where it fits

It is how a sentence becomes a listing, an application, or an intake, on top of ai-kit. The studio's bet is that the interface to an economic life is closer to speech than to a spreadsheet.

LicenceMIT
Registry releasev0.1.3
Install
Sourcebitbaum/ai-forms
Declared consumers7 repositories

API and examples

Documentation from source commit 1afccb462f5d · 2026-11-07. Source documentation can include work beyond the registry release.

Read the package documentation

![npm](https://www.npmjs.com/package/ai-forms) ![CI](https://github.com/bitbaum/ai-forms/actions/workflows/ci.yml) ![License: MIT](LICENSE)

Headless AI form filling and conversational refinement.

Forms are the worst part of most software. This package makes a form answer to plain language — fill it from a description, then keep talking to it ("shorter", "move the date to next Friday", "actually call it something else") until it is right.

pnpm add ai-forms

No provider SDK, no markup, no styles. You pass in a function that calls whatever model you already use; the package handles prompting, parsing, sanitising, and the merge rules that decide who wins when the model and the user disagree.

Works with any model (OpenAI, Anthropic, Groq, Gemini, local — anything you can wrap in (prompt) => Promise<string>), any framework on the server, and React on the client. The React hook is optional; the core is framework-free.


Try it

git clone https://github.com/bitbaum/ai-forms
cd ai-forms/examples/next && pnpm install && pnpm 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.

What "done" looks like

Five properties, all of which this package holds:

  1. Every form can be filled from prose. The user types what they want in one box; the form fills in.
  2. Every filled form can be changed by talking to it. Follow-up instructions apply to what is already there. This is the part almost everyone gets wrong — see "Why refinement silently fails" below.
  3. The user's own typing is never overwritten by a fill. Only gaps get filled, plus fields explicitly marked as carrying a default rather than intent.
  4. Every AI edit is reversible and visible. undo() restores the previous values; isAiTouched(name) marks which fields the assistant wrote.
  5. The page-aware chat can drive the form. The small assistant in the corner sees what is on screen and writes into the open form, rather than telling the user which buttons to press.

Why refinement silently fails

The usual bug is one line of merge logic. An implementation protects user input by letting existing values win on conflict — correct for filling an empty form, fatal for changing a full one. Ask it to rewrite a description that already has text and the existing value overwrites the model's output, so nothing happens. The form appears to ignore the user.

The fix is intent:

IntentWho winsWhy
`fill`the userThe model is filling gaps. Typed input is intent and must survive.
`refine`the modelChanging the field *is* the request. Omitted fields keep their values.

useAiForm infers the intent from whether the form is empty, so the user never picks a mode.

The same mistake has a sibling: a single minimum input length. A fill description should be a sentence, but a refine instruction is legitimately two words ("shorter"). One floor for both rejects exactly the instructions that matter. See MIN_INSTRUCTION_LENGTH.


Adopting it — three files

1. Declare the form's fields (SSOT)

// src/config/ai-forms.ts
import { defineFields, type FormTarget } from 'ai-forms';

export const GOAL_FORM: FormTarget = {
  key: 'goal',
  name: 'Goal',
  fields: defineFields([
    { name: 'title', label: 'Title', type: 'text', required: true, maxLength: 120 },
    { name: 'description', label: 'Description', type: 'textarea', maxLength: 500 },
    { name: 'targetDate', label: 'Target date', type: 'date' },
    { name: 'ownerId', label: 'Owner', type: 'text', aiExcluded: true },
  ]),
  instructions: ['Target dates are calendar dates, never relative phrases.'],
};

export const AI_FORMS = [GOAL_FORM /* , ... */];

aiExcluded fields are never shown to the model and never writable by it — ids, tokens, ownership columns.

2. Mount one route for the whole app

// src/app/api/ai/form-assist/route.ts
import { createFormAssistHandler } from 'ai-forms/server';
import { AI_FORMS } from '@/config/ai-forms';
import { getCurrentUserId } from '@/lib/session';

export const POST = createFormAssistHandler({
  targets: AI_FORMS,
  authorize: async () => (await getCurrentUserId())
    ? { ok: true }
    : { ok: false, status: 401, error: 'Sign in to use the assistant.' },

  // Any provider. `complete` just has to return the model's text.
  complete: async ({ system, prompt, maxTokens, temperature }) => {
    const res = await fetch(`${process.env.LLM_BASE_URL}/chat/completions`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${process.env.LLM_API_KEY}`,
      },
      body: JSON.stringify({
        model: process.env.LLM_MODEL,
        max_tokens: maxTokens,
        temperature,
        messages: [
          { role: 'system', content: system },
          { role: 'user', content: prompt },
        ],
      }),
    });
    const json = await res.json();
    return json.choices[0].message.content;
  },
});

The package never owns API keys, model choice, budgets, or fallback policy — the app passes its own caller. That is deliberate: you already have retry, spend caps, and a fallback chain, and a form library has no business owning any of them.

Field specs live on the server, so a client can never widen the set of fields the model may write.

3. Use the hook in the form

const form = useAiForm({ target: 'goal', fields: GOAL_FORM.fields });

<input value={form.text('title')} onChange={e => form.setValue('title', e.target.value)} />
<textarea value={form.text('description')} onChange={e => form.setValue('description', e.target.value)} />

<AiFormAssistant
  form={form}
  suggestAfterFill                       // propose improvements right after a fill
  labels={{ fillTitle: 'Mit KI ausfüllen', /* … your language */ }}
  classNames={{ root: 'card', textarea: 'input', submit: 'btn-primary', suggestion: 'chip' }}
/>

The hook owns form state. That is the point: the user and the assistant write to the same store, which is what makes "now change the date" work at all. Forms adopting this usually get shorter, because a useState per field collapses into one.

One assistant control for every app. AiFormAssistant is the behaviour — fill, change, undo, "N fields updated", suggestions — with no styling: each app has its own design-token SSOT, so you pass words (labels) and token classes (classNames). Improve the experience here and every adopter gets it on the next install; a hand-rolled bar per app is how the same fix used to be needed four times. Rendering your own around useAiForm still works.

What the assistant does once the form has content. The same box takes three kinds of input: a change ("shorter", "write the description"), more information (paste the advert — empty fields fill, filled ones are left alone), or nothing at all — Suggest improvements asks the model what it would change, and each suggestion is one tap that runs as an ordinary refine, with undo. Facts (dates, numbers, contacts) are never invented; prose you ask for is written.


API

ai-forms

ExportPurpose
`defineFields(specs)`Identity helper that pins field names into the type
`runFormAssist({ target, request, complete })`The whole pipeline: prompt → model → parse → sanitize → merge
`mergeValues(ai, existing, intent, fields)`SSOT for who wins on conflict
`sanitizeValues(raw, fields)`Coerce to declared types; drop invented and excluded fields
`parseAssistResponse(text)`Extract JSON from fenced or prose-wrapped completions
`MIN_INSTRUCTION_LENGTH`Per-intent input floors

ai-forms/server

createFormAssistHandler(config) → (Request) => Promise<Response>. Web-standard, so it drops straight into a Next.js App Router route. authorize runs before any model call.

ai-forms/react

useAiForm(options) → values, setValue, ask / fill / refine, busy, error, transcript, changed, isAiTouched, undo, canUndo, reset, suggest / suggestions / suggesting / applySuggestion.

AiFormAssistant({ form, labels?, classNames?, icon?, suggestAfterFill? }) — the shared control described above.

User-facing errors are English by default; pass messages to createFormAssistHandler (or per form on the FormTarget) to speak the app's language. intent: "suggest" goes to the same route; respondSuggest customises its envelope.

readPageContext(selector?, maxChars?) reads rendered text from the page, so a page-aware assistant can only claim to see what is actually on screen.


Guarantees the tests hold

pnpm run verify runs the build and the suite. The suite exists to stop specific regressions, not for coverage:

  • a fill never overwrites what the user typed; an overridable default may be replaced
  • a refine applies to existing text and keeps the fields it did not return
  • a two-word refine instruction is accepted where a two-word fill is rejected
  • prior turns and current values reach the model on a follow-up
  • aiExcluded fields never appear in a prompt and can never be written
  • undeclared fields, uncoercible numbers, and unknown option values are dropped rather than guessed
  • calendar dates do not drift a day (local date, never toISOString)
  • a turn that changes nothing reports that honestly instead of claiming success
  • an unauthorised request never reaches the provider

License

MIT


Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use ai-forms.

Changelog and releases

Read the repository changelog

This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.

0.1.3

No authored release notes were found for this tag when this record was made. The links identify the verified source; missing feature history is not invented.

No release notes have been retrieved. This does not mean the package has never changed.

Recent source changes

Repository activity can include unreleased work; it is not a list of deployed changes.

  1. chore(deps-dev): bump the minor-and-patch group with 2 updates (#63)
  2. docs(agents): the org rules block, generated from bitbaum/fleet (#61)
  3. chore(deps-dev): bump the minor-and-patch group with 2 updates (#60)
  4. chore(deps): clear dev-toolchain audit findings inside existing ranges (#59)
  5. docs(agents): the org rules block, generated from bitbaum/fleet (#58)
  6. fix(release): apply verified build-output staging contract (#57)
  7. chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it (#56)
  8. docs(agents): the org rules block, generated from bitbaum/fleet (#55)
All releases · All tags

Where it is declared

Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.

ai-kitevigLokiSurf Your Life3 other repositories

Sources and verification

Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.

Documentation from source commit 1afccb462f5d · 2026-11-07. Source documentation can include work beyond the registry release.

Package verification runs