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.
The model in one minute
Section titled “The model in one minute”One YAML file per form under a forms/ directory. Each form declares an
entity (the table name) and its fields. At startup Intake:
- parses and validates every form file,
- compiles each into a fixed prepared
INSERT(identifiers from YAML only), - reconciles the table (
CREATE/ additiveALTER).
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_requestForm definition (YAML)
Section titled “Form definition (YAML)”# forms/demo_request.yaml → route: POST /f/demo_requestentity: demo_request # -> table name AND {form_id} route segmentversion: 2 # bump on additive change; drives migrationstrict: 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):
entityand everynamemust 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:
| key | default | meaning |
|---|---|---|
version | 1 | bump on additive change; documents intent, drives migration |
strict | true | reject 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 |
Field types & validation
Section titled “Field types & validation”| YAML type | DuckDB column | validation |
|---|---|---|
string / text | VARCHAR | UTF-8 valid, no control chars, max_length (runes), format, pattern |
int | BIGINT | integral, min / max |
float | DOUBLE | min / max |
bool | BOOLEAN | truthy coercion (true/1/on/yes/t/y ↔ false/0/off/no/f/n) |
enum | VARCHAR | value ∈ values |
timestamp | TIMESTAMP | RFC 3339 |
uuid | VARCHAR | canonical UUID string (stored as text, validated) |
json | JSON | opt-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
NULLfor optional fields. - URL-encoded/multipart values arrive as strings and are coerced; JSON values
keep their native type (a JSON number for a
stringfield is rejected). - All violations for a submission are collected and returned together.
System fields
Section titled “System fields”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.
| field | type | source |
|---|---|---|
id | VARCHAR | generated ULID (monotonic → CDC-friendly watermark) |
received_at | TIMESTAMP | server clock (UTC) |
form_id | VARCHAR | route segment |
source_ip | VARCHAR | the client_ip_header value from the gateway |
user_agent | VARCHAR | request header (truncated to 512 bytes) |
request_id | VARCHAR | propagated X-Request-Id, else generated |
Posting a submission
Section titled “Posting a submission”POST /f/{form_id} accepts three content types:
# urlencodedcurl -X POST https://api.kis.ai/f/demo_request \
# JSONcurl -X POST https://api.kis.ai/f/demo_request \ -H 'Content-Type: application/json' \
# multipart (file parts are ignored; value fields only)curl -X POST https://api.kis.ai/f/demo_request \Success:
{ "id": "01KX50JFK1M0CXMHRTEEG4AEGP", "ok": true }Wiring an Astro form
Section titled “Wiring an Astro form”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.
Response & error semantics
Section titled “Response & error semantics”No input is ever echoed in a response. Errors are generic — at most a list of offending field names, never reflected values.
| status | body | when |
|---|---|---|
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-After | per-IP bucket empty |
503 | {"error":"shedding"} + Retry-After | global ceiling hit or writer queue full |
403 | {"error":"forbidden"} | gateway shared-secret required and wrong/missing |
Schema evolution
Section titled “Schema evolution”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.
Notifications (email / Teams)
Section titled “Notifications (email / Teams)”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); thetolist is per-form. - Teams uses the form’s
webhookif set, otherwise the globalnotify.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).