Home

The right question, in the right moment.

Your people answer the way they speak — type, talk, or hand over an image. NoForms listens, asks only what matters next, and hands the answer to wherever it needs to go — signed and verifiable.

Everything below is the same idea: every JSON POST in the world is a form. NoForms is no form — a conversation between two motivated parties, never an interrogation of one.

Every claim below is anchored to a green test in this repo — 734 tests across 40 suites, last run today.


01

Describe the conversation in one sentence.

No drag-and-drop builder, no question-type picker. Say what you'd like to know about your people; the platform proposes the conversation question by question and tells you, per ask, how confident it was — so you know what to reshape.

40 green · ai-parse-route29 green · placeholder-route

Describe what you'd like to know. The platform returns a typed structure ready to publish — and a confidence score per question so you know what to refine before going live.

curl
curl -X POST http://localhost:3000/api/ai/generate-form \
  -H 'Content-Type: application/json' \
  -d '{
    "prompt": "Job application for a junior backend engineer in Nairobi",
    "preferences": { "style": "concise", "includeValidation": true }
  }'

Response includes the proposed structure (persisted as status: "draft"), an overall confidence (0–1), and per-question aiSuggestionReason so the platform can explain its choices.


02

Listen the way they speak.

One sentence — “Ada, ada@x.com, head of ops at Acme, based in Nairobi” — can answer five questions in one breath. Your people lead; the platform follows.

64 green · multi-field-parser35 green · schema-validation25 green · form-validator

The input accepts any natural phrasing. The platform reads what was said against what you need to know and returns { questionId: { value, confidence } } for every answer it heard. Three layers in order:

  1. Cache. Things we've heard before — identical or near-identical phrasing — resolve instantly (confidence ≥ 0.85, no model call).
  2. Embeddings. Things we've heard something similar to surface as hints at threshold 0.60, accepted at 0.85.
  3. Model. Anything new goes to the model with everything we already know about this person. Results ≥ 0.7 are remembered so the next person who phrases it the same way is answered for free.

The platform handles the formats people actually use — dates like "next Friday", phones with or without country codes, country names becoming ISO 3166 codes, currency strings becoming structured amounts. None of this is your problem; it's one listener doing the work.


03

Ask only what's relevant right now.

The best question is the one a person is glad you asked. Where other tools give you six expressions (visibleIf, enableIf, requiredIf, …) NoForms collapses them to one: a sentence describing when the question matters — or a typed rule when you need exact semantics.

38 green · field-relevance44 green · relevance-generator

Write a sentence. The platform asks a small model whether the question applies, given everything this person has already told you.

jsonc
{
  "id": "spouseName",
  "label": "Spouse's name",
  "relevance": "Ask only if the person is married and has a named partner."
}

When you need exactness — numeric thresholds, regulated branching — the same property accepts a typed expression evaluated deterministically:

jsonc
{
  "id": "phoneNumber",
  "relevance": {
    "when": ["contact_preference", "==", "phone"],
    "and": [["age", ">=", 18]]
  }
}

Operators: ==, !=, <, <=, >, >=, in, not_in, exists, truthy, contains. Compose with and / or. Missing values fail gracefully instead of throwing.

The same ask, rephrased for the moment:

Enter your full name
How should we address you?
Enter payment information
How would you like to be paid?
Verify your identity
We'll send a $0 charge to confirm it's you
Date of birth
We need to make sure you're 18+ — what's your birth year?
Upload identity document
Snap a photo of an ID — we'll read the rest

The right side asks for the same information. It just doesn't sound like a queue at the DMV.


04

Accept whatever they hand you.

People answer the way they want to. Text. Voice. A photo of a business card. A snap of a driver's licence. One listener, one response shape — your other code never branches on which way the person chose to answer.

64 green · multi-field-parser13 green · vision-extraction9 green · audio-transcribe

Identity onboarding (KYC) is the canonical case: one image of an ID answers name, date of birth, document number, and expiry in a single step — instead of seven separate asks. POST /api/ai/extract-image with a data URL (or a fetch-able https URL) and the same question definitions you'd send to /api/ai/extract:

curl
curl -X POST http://localhost:3000/api/ai/extract-image \
  -H 'Content-Type: application/json' \
  -d '{
    "image": "data:image/jpeg;base64,'$(base64 -w0 business-card.jpg)'",
    "fieldDefinitions": [
      { "id": "name",    "type": "text",  "label": "Full name" },
      { "id": "email",   "type": "email", "label": "Email" },
      { "id": "company", "type": "text",  "label": "Company" }
    ]
  }'

Response shape is identical to the text listener — downstream code never branches on the modality your person chose:

json
{
  "ok": true,
  "data": {
    "parsedFields": {
      "name":    { "value": "Ada Lovelace",  "confidence": 0.94 },
      "email":   { "value": "ada@x.com",     "confidence": 0.91 },
      "company": { "value": "Analytical Co", "confidence": 0.83 }
    },
    "model": "openai/gpt-4o-mini",
    "source": "vision-llm"
  }
}

The voice path is the same shape — push-to-speak → whisper transcription → text listener. When a provider is missing, the platform returns 503 with an actionable hint, never a silent fallback that lets your person think their answer landed.


05

Remember what they tell you.

Every completed conversation lands as a row — the structured answer, the version of the ask, and the metadata you care about (origin, user agent, UTM tags, time spent thinking).

30 green · form-progress28 green · form-transformer6 green · admin-leads

POST /api/forms/[id]/submit accepts the parsed answer and reconciles it with what was asked before writing. Returns an id, completion timestamp, and a summary of what the platform understood.

curl
curl -X POST http://localhost:3000/api/forms/$FORM_ID/submit \
  -H 'Content-Type: application/json' \
  -d '{
    "data": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "country": "KE"
    },
    "metadata": { "source": "landing", "utm_source": "guide" }
  }'

06

React when something happens.

When a conversation completes, the platform announces it. Subscribers — delivery to a webhook, an email thank-you, analytics, anything you add — respond independently. None can block the response back to your person.

9 green · form-events7 green · resend-client

Adding a new integration is one file. No route changes. No feature flags. No maybe* branches piling up in the API layer.

typescript
// src/lib/events/subscribers/slack-ping.ts
import { onFormSubmitted } from "@/lib/events/form-events";

onFormSubmitted(async (event) => {
  if (event.formSlug !== "support-intake") return;
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    body: JSON.stringify({
      text: `New support ticket: ${event.data.subject}`,
    }),
  });
});

Then import it once from subscribers/index.ts. Tests cover handler isolation: a throwing or rejecting subscriber never stops the others, and never stops the response your person is waiting on.


07

Hand off to the next tool — signed.

Set a URL. Every completed conversation POSTs there with a deterministic JSON envelope and an HMAC-SHA256 signature your handler can verify byte-for-byte. No “did this really come from us” doubt.

32 green · webhook-delivery3 green · webhook-delivery integration

Configure on the conversation (or via the API):

json
{
  "schema": {
    "logic": {
      "webhook_url": "https://api.your-service.com/hooks/noforms",
      "webhook_secret": "wh_sec_…"
    }
  }
}

The payload you receive:

json
{
  "event": "form.submission.created",
  "formId": "f1a3c…",
  "formTitle": "Job application",
  "submissionId": "s9d2e…",
  "submittedAt": "2026-05-24T10:00:00.000Z",
  "data": { "name": "Ada", "email": "ada@example.com" },
  "metadata": { "source": "landing", "utm_source": "guide" }
}

Request headers:

  • X-NoForms-Eventform.submission.created
  • X-NoForms-Deliveryuuid (per attempt)
  • X-NoForms-Signaturesha256=<hex64>
  • User-AgentNoForms-Webhook/1.0

Verify in Node:

typescript
import { createHmac, timingSafeEqual } from "node:crypto";


function isValid(secret: string, rawBody: string, sigHeader: string) {
  const expected = "sha256=" +
    createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}

Verify in Python:

python
import hmac, hashlib

def is_valid(secret: bytes, raw_body: bytes, sig_header: str) -> bool:
    expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig_header)

08

Different voices, same conversation.

A mortgage from a couple. A vendor onboarding across legal + finance + ops. A KYC check where a vision agent reads the ID and a human confirms. Many actors — human and machine — collaborating on one outcome. The orchestrator decides who's asked what, when, and with how much transparency.

9 green · form-events12 green · collaboration-events17 green · sharing-events32 green · completion-rule-engine

Three transparency modes cover everything we've seen in production. The orchestrator picks one per thread of the conversation — sensitive questions can be private inside an otherwise public conversation.

  • Public. A group chat over a conversation. Every voice sees every answer as it lands; one person can quote another. Joint applications, family decisions, small-team intake.
  • Private with presence. Each voice has their own surface; content stays with them. The shared lobby shows who's been asked, who's started, who's done. Vendor onboarding, mixed-trust circles, multi-witness collection.
  • Aggregate. Individual answers never surface. Distributions and counts only. Polls, anonymous surveys, pulse checks.

And the second axis: humans aren't the only voices. Machine voices (a vision extractor reading an ID, an API verifying a company registration, an embedding lookup completing an address) are first- class participants. The orchestrator fires the same events for both:

typescript
onParticipantInvited((event) => {
  if (event.kind === "agent") {
    // tell ops a verification agent just took over the next thread
    notify(`${event.identifier} is verifying ${event.role}`);
  } else {
    // send the human their magic link
    emailMagicLink(event.identifier, event.conversationId);
  }
});

The lifecycle: participant.invited → participant.started → thread.completed → participant.completed → form.submission.created. Subscribe to any of them; ignore the rest. The full design — primitives, identity tiers, roll-out plan — lives at docs/multi-participant-conversations.md in the repo.

Inviting people works the way you already know. By link, by email, by domain, with a role you assign per voice — the same share dialog Google Docs and Figma have spent a decade teaching everyone. Owners, co-organisers, participants, commenters, observers, inspectors (read-only API), agents (machine voices). The difference: instead of giving free roam over the document, the orchestrator routes each question to the right voice at the right moment.

  • Multiplayer presence. Avatar row in the header, question-level "Sarah is on this one" dot, ephemeral cursor whispers — the Figma model translated to questions. Governed by presenceVisibility.
  • Comments at question granularity. @-mentions notify; resolved comments persist as history (Docs pattern).
  • Proposals. Agents and commenters can propose answers; the assigned voice accepts, rejects, or counter-proposes. Maps cleanly to Suggestion Mode and Branches in the tools you already use.
  • Activity log. Every invite, start, completion, role change, comment, proposal, accept, reject is an event. Subscribers audit. Owners replay.

09

Worthy of their trust.

The delivery layer refuses to surprise you. Loopback, RFC1918, link-local, cloud-metadata endpoints — blocked unless an operator opens them. Signing is on the moment a secret is set. Personal answers stay out of logs.

32 green · webhook-delivery
  • SSRF guard. Refuses to POST to 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254 (AWS metadata), IPv6 ULA, and .local/.internal hostnames — unless WEBHOOK_ALLOW_PRIVATE_HOSTS=true is set (intended for development).
  • HMAC-SHA256 signing. When the conversation has a webhook_secret, the body is signed and the hex digest sent in X-NoForms-Signature. Use a constant-time comparison on your end.
  • Personal answers stay out of logs. Delivery outcomes log only structural metadata — formId, submissionId, deliveryId, status, durationMs, reason. What the person actually told you never appears.
  • Fail-loud config. Compose-managed deployments use ${VAR:?} markers on every secret. An unset secret refuses to start the app — never a silent boot with an empty default.

Try it now

Try a conversation.

The seeded demo has twelve questions across three threads. Paste one sentence; watch the platform understand five. Click finish; see exactly what your webhook would receive.

734 tests · 40 suites · last verified today