Skip to content
Talk to our solutions team

Defining Forms

This page is for developers wiring an Astro (or any static) site to Intake: authoring form schemas and posting submissions. Intake is a standalone, hardened HTTP binary that accepts anonymous form submissions from static sites, validates each payload against a per-form YAML schema, and writes it durably to DuckDB (default) or JSONL.

Deploying and running the service behind the gateway is covered in Operations; running or adding to the test suite is covered in End-to-End Testing, the design rationale and decisions live in the design spec.

Intake is self-contained: it imports no kis.ai platform libraries. The gateway is the trust boundary; the binary itself is unauthenticated by design and must only ever be reachable through the gateway.

One YAML file per form under a forms/ directory. Each form declares an entity (the table name) and its fields. At startup Intake:

  1. parses and validates every form file,
  2. compiles each into a fixed prepared INSERT (identifiers from YAML only),
  3. reconciles the table (CREATE / additive ALTER).

A submission arrives at POST /f/{form_id}, where form_id is the entity name. The payload is validated against the schema, unknown fields are rejected (strict by default), and the accepted row is written durably before the 200.

forms/demo_request.yaml → POST /f/demo_request → table demo_request
# forms/demo_request.yaml → route: POST /f/demo_request
entity: demo_request # -> table name AND {form_id} route segment
version: 2 # bump on additive change; drives migration
strict: true # reject unknown fields (default: true)
honeypot: website_url # if this field is present & non-empty -> silent 200, discard
fields:
- { name: email, type: string, required: true, format: email, max_length: 320 }
- { name: full_name, type: string, max_length: 200 }
- { name: company, type: string, max_length: 200 }
- { name: team_size, type: int, min: 1, max: 100000 }
- { name: use_case, type: enum, values: [banking, insurance, healthcare, other] }
- { name: message, type: text, max_length: 5000 }
- { name: consent, type: bool, required: true }

Rules enforced at load (a violation is a fatal startup error, not a warning):

  • entity and every name must match ^[a-z_][a-z0-9_]*$.
  • A field name may not collide with a system field.
  • No duplicate field names; enum fields must declare values.
  • The form file itself may not contain unknown top-level keys.
  • honeypot, if set, must not also be a declared field.

Optional keys:

keydefaultmeaning
version1bump on additive change; documents intent, drives migration
stricttruereject unknown fields with 400; set false to silently ignore them
additional_properties—set to capture to fold unknown fields into a single _extra JSON column instead of rejecting them
honeypot—a trap field name; if a submission fills it, the request is silently 200’d and discarded
YAML typeDuckDB columnvalidation
string / textVARCHARUTF-8 valid, no control chars, max_length (runes), format, pattern
intBIGINTintegral, min / max
floatDOUBLEmin / max
boolBOOLEANtruthy coercion (true/1/on/yes/t/y ↔ false/0/off/no/f/n)
enumVARCHARvalue ∈ values
timestampTIMESTAMPRFC 3339
uuidVARCHARcanonical UUID string (stored as text, validated)
jsonJSONopt-in; stored as canonical JSON

Per-field options: required, max_length, min, max, values (enum), format (email | url), pattern (RE2 regex).

Notes:

  • Required means present and non-empty. An empty string counts as “not provided” and stores NULL for optional fields.
  • URL-encoded/multipart values arrive as strings and are coerced; JSON values keep their native type (a JSON number for a string field is rejected).
  • All violations for a submission are collected and returned together.

Six fields are server-injected, never client-writable, and always present as columns. Any attempt to send them as input is treated as an unknown field.

fieldtypesource
idVARCHARgenerated ULID (monotonic → CDC-friendly watermark)
received_atTIMESTAMPserver clock (UTC)
form_idVARCHARroute segment
source_ipVARCHARthe client_ip_header value from the gateway
user_agentVARCHARrequest header (truncated to 512 bytes)
request_idVARCHARpropagated X-Request-Id, else generated

POST /f/{form_id} accepts three content types:

Terminal window
# urlencoded
curl -X POST https://api.kis.ai/f/demo_request \
-d [email protected] -d team_size=25 -d use_case=banking -d consent=true
# JSON
curl -X POST https://api.kis.ai/f/demo_request \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","team_size":25,"use_case":"banking","consent":true}'
# multipart (file parts are ignored; value fields only)
curl -X POST https://api.kis.ai/f/demo_request \
-F [email protected] -F consent=true

Success:

{ "id": "01KX50JFK1M0CXMHRTEEG4AEGP", "ok": true }

Point the form action at the route; the name attributes must match the form fields. Path-based routing keeps the form identity out of the mutable payload.

<form method="POST" action="https://api.kis.ai/f/demo_request">
<input type="email" name="email" required />
<input type="text" name="full_name" />
<input type="number" name="team_size" min="1" />
<select name="use_case">
<option>banking</option><option>insurance</option>
<option>healthcare</option><option>other</option>
</select>
<textarea name="message"></textarea>
<label><input type="checkbox" name="consent" value="true" required /> I agree</label>
<!-- honeypot: visually hidden; bots fill it, humans don't -->
<input type="text" name="website_url" tabindex="-1" autocomplete="off"
style="position:absolute;left:-5000px" aria-hidden="true" />
<button type="submit">Request a demo</button>
</form>

For a cross-origin fetch() submission, add the site’s origin to the CORS allowlist (cors.origins) so the browser preflight succeeds.

No input is ever echoed in a response. Errors are generic, at most a list of offending field names, never reflected values.

statusbodywhen
200{"id":"…","ok":true}accepted & durable
200{"ok":true}honeypot tripped (silently discarded)
400{"error":"validation_failed","fields":["email",…]}validation / unknown field
404{"error":"unknown_form"}form_id not loaded
413{"error":"too_large"}body over max_body_bytes
415{"error":"unsupported_media_type"}content type not in the allowlist
429{"error":"rate_limited"} + Retry-Afterper-IP bucket empty
503{"error":"shedding"} + Retry-Afterglobal ceiling hit or writer queue full
403{"error":"forbidden"}gateway shared-secret required and wrong/missing

Migration is additive-only, reconciled at startup:

  • New form → CREATE TABLE.
  • New field added to an existing form → ALTER TABLE ADD COLUMN.
  • Retyping or removing a column is a destructive diff → Intake refuses to start and logs which column. Do the migration explicitly; the binary never drops or rewrites data.

Bump version when you make an additive change. It’s your record of intent.

Each accepted submission can be delivered to humans, by email and/or a Microsoft Teams channel: configured per form. Add a notify: block to the form YAML; set either channel, or both:

notify:
email:
subject: "New demo request" # optional; default: "New <entity> submission"
teams:
webhook: "" # empty -> use the global notify.teams.default_webhook
  • Omit a channel block to skip it, a form with no notify: block sends nothing (the submission is still stored).
  • Email requires the shared SMTP transport to be configured in the main config (notify.email.smtp_host); the to list is per-form.
  • Teams uses the form’s webhook if set, otherwise the global notify.teams.default_webhook. Create the webhook in Teams via an Incoming Webhook connector on the target channel; Intake posts a MessageCard with the submission’s fields.

Delivery is asynchronous and best-effort: it happens after the durable write, so it never slows the 200 and a failed email/Teams call never loses the lead. The store (DuckDB/JSONL) is the source of truth. Notifications retry with backoff; a persistently failing one is logged and counted, not retried forever. The whole feature is gated by notify.enabled in the main config (see operating).