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_propertiesset to capture to fold unknown fields into a single _extra JSON column instead of rejecting them
honeypota 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/yfalse/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).