Form triggers
A form trigger publishes a public, login-free web page where anyone with the
link can fill in a form and start a workflow run. It is the
business-user counterpart to the webhook trigger: instead of
a server-to-server POST with a secret, a human submits an HTML form.
Each workflow may have one form. It has:
- a unique URL
GET|POST /forms/{token}on the public inference host (https://ai-api.myra.eu), where{token}is a 128-bit random hex string, - an enabled flag and an optional built-in CAPTCHA,
- no secret — the unguessable token is the sole URL guard (the page is meant to be shared). An unknown, disabled, or unpublished form returns a uniform "not available" page, so the token never confirms whether a form exists.
The form fields are derived from the workflow's trigger node schema — the same
input_schema the builder edits and that downstream steps reference as
{{step_0.output.<field>}} (where step_0 is the trigger node's step id). There is
no second schema: the page renders, the server validates, and the graph
references all read the one schema.
The run executes as the workflow's owner (workflow.created_by), with exactly
the tools that owner configured. The submitter supplies only the field values;
the workflow, owner, tenant and gateway are all resolved server-side from the stored
form, so a submitter can never target another workflow, owner or tenant.
A form is public and anonymous
Anyone with the link can start a run that drives the workflow's agents with their full tool access, without signing in. Only enable a form on a workflow you trust with anonymous input. (This is the same posture as a public webhook, but without a shared secret.)
Rendering the form
Returns a self-contained, mobile-responsive HTML page (tenant branding, one input per
schema field, an optional CAPTCHA, and a submit button). Query parameter ?lang=de|en
selects the language (otherwise the Accept-Language header, English fallback).
The API host is the canonical form origin
The form page is served only by the public inference host
(https://ai-api.myra.eu), which is where the product-generated link points. A link
that instead lands on the app host (https://ai.myra.eu) — mistyped, shortened, or
rewritten by a mail client — is redirected with a method- and body-preserving
307 to the same path on the API host, so it still opens the real form instead of a
blank page. The redirect is intentionally uncacheable, so the canonical origin can be
changed later without stale caches pinning it.
Submitting the form
POST https://ai-api.myra.eu/forms/{token}
Content-Type: application/x-www-form-urlencoded
full_name=Ada&age=42&topic=Sales&__aig_version=<signed>
On success the response is a minimal receipt page — a confirmation and an opaque
reference id (the run id). It never echoes the submitted values and never exposes any
run internals. The submission becomes the run's trigger_input and is available to
the workflow as {{step_0.output.<field>}}.
Accepted request shape
- Method
POST, content typeapplication/x-www-form-urlencodedonly (a JSON or multipart body is rejected). Max body 64 KB. - One parameter per declared field, plus the hidden
__aig_versionbinding token (and, when CAPTCHA is on,__aig_captcha+__aig_captcha_token). - Field values are treated strictly as data — a value containing
{{…}}is stored literally and is never interpreted as a template (single-pass substitution).
What is rejected (fail-closed, 4xx)
| Condition | Status |
|---|---|
| Wrong content type (not urlencoded) | 415 |
| Body over 64 KB / invalid UTF-8 | 413 / 400 |
| Unknown field (not in the schema) | 400 |
| Missing a required field | 400 |
Wrong type — a number field whose value is not a strict decimal (no exponent, inf, nan, hex, or whitespace) |
400 |
A select value not in the field's options |
400 |
| A field value over 8 KB, or more than 100 fields | 400 |
| A repeated or valueless parameter (non-string value) | 400 |
| Missing/incorrect CAPTCHA answer (when enabled) | 400 |
| The workflow was re-published since the page loaded (schema changed) | 409 — reload |
| Unknown/disabled/unpublished form, or the workflow's workspace feature is off | 404 |
| Too many submissions (per-IP or per-form rate limit), or too many in-flight runs | 429 |
| The in-flight-run count could not be read (transient database error) | 503 — retry |
Per-IP and per-form rate limits plus the in-flight-run cap are the primary anti-abuse controls; the optional CAPTCHA is a lightweight built-in add-on (a visible arithmetic challenge, no third-party service — it deters naive bots, and a solved challenge may be replayed within its short validity window).
The submitted content inherits the platform's PII protection at run execution: values flow through the same guardrails masking on agent-step input, and the workflow delivery egress fence blocks external delivery of a run that touched personal data — exactly like every other trigger kind.
Anonymous file uploads
A form may carry file fields, letting an anonymous submitter attach documents to a run (for example a citizen uploading a form to a public-service workflow). Because the submitter is unauthenticated, uploaded bytes go to a dedicated anonymous store — never the signed-in chat attachment store — with strict, fail-closed limits.
Accepted files. Only this allow-list, and the file's real type is verified from its
magic bytes (not the browser-supplied Content-Type or the filename):
| Type | Accepted as |
|---|---|
application/pdf (%PDF- header) |
|
| PNG | image/png |
| JPEG | image/jpeg |
| WebP | image/webp (RIFF/WEBP header) |
| Plain text | text/plain (valid UTF-8, no control bytes other than tab/newline/carriage-return) |
| Word (.docx) | Office Open XML word document |
| Excel (.xlsx) | Office Open XML spreadsheet |
Rejected (each fails the upload, no bytes stored):
| Rejected input | Why |
|---|---|
Any type not on the allow-list — executables (ELF/PE), scripts, archives, .zip |
Not an accepted type |
A file whose bytes don't match its declared Content-Type or its filename extension |
Spoofed type/extension |
| A file over 10 MB | Per-file size cap |
| A submission exceeding 5 files or 25 MB total | Per-submission quota |
| An empty file, or a filename that reduces to nothing after sanitizing | Malformed |
| An upload to an unknown, disabled, or decommissioned-tenant form | Fail-closed on the target |
| A file whose bytes are flagged by the malware scanner (when the gateway has opted in) | Infected — 422, nothing stored |
| A file that could not be scanned because the malware scanner is unreachable (when the gateway has opted in) | Fail-closed — 503 + Retry-After, nothing stored |
The stored content_type is the server-derived type from the magic-byte sniff; the
filename is sanitized (path separators, quotes, control bytes and NUL/CR/LF removed,
clamped to 255 characters). The client-declared type and filename are treated as hostile
and used only to reject a mismatch.
Malware scanning (fail-closed, opt-in per gateway). Malware scanning is enabled per
gateway (av_scan.enabled in the form's gateway config) and is off by default — a form
whose gateway has not opted in skips this step and the upload proceeds normally. When the
gateway has opted in: after the magic-byte type check and before any bytes are persisted,
the raw file is streamed to a self-hosted ClamAV daemon (clamd, INSTREAM). An infected
verdict rejects the upload (422, signature logged operator-side only — never returned to the
submitter). If clamd cannot be reached, times out, or returns an error, the upload is
rejected, not accepted unscanned — a transient 503 with a short Retry-After so the
widget backs off and re-submits. The virus scanner is configured for your deployment
by Myra and is consulted only for an opted-in gateway.
Configuring a file field (builder)
A form author adds a file field in the workflow builder like any other field. It is
persisted in the same trigger input_schema (there is no second schema) as a string
property carrying a format: "aig-file" sentinel, with optional author narrowing:
"cv": { "type": "string", "title": "CV", "format": "aig-file",
"x-accept": ["application/pdf", "text/plain"], "x-max-size": 2097152 }
x-accept(optional) — the subset of the allow-list above that this field accepts; omitted/empty means every supported type. It is a narrowing on top of the magic-byte allow-list, enforced server-side at submit — never the security boundary.x-max-size(optional) — a per-field byte cap ≤ 10 MB (0/omitted = the store's 10 MB per-file cap).- A form allows at most 5 file fields (the per-submission file quota — the builder blocks a sixth).
- The field's downstream output
{{step_0.output.<key>}}resolves to the file's extracted text (PII-masked on a PII gateway), not the raw bytes — so a file field feeds an agent step exactly like a text field.
Uploading a file
POST https://ai-api.myra.eu/forms/{token}/upload
Content-Type: multipart/form-data; boundary=...
(one file part + an optional submission_token text part)
A file is uploaded to a dedicated endpoint (separate from the form POST above,
which is capped small for its urlencoded body). This endpoint disk-buffers the upload so
it can accept a large body, and applies its own tighter rate limit. The widget on the
form page uploads each attached file here before the form is submitted, then carries
the returned submission_token on the final form POST so the run can claim the files.
Request shape (accepted):
- Method
POST, content typemultipart/form-datawith a boundary. - Exactly one file part — the single part carrying a
filename— per request (upload additional files with additional requests, reusing the samesubmission_token). - An optional
submission_tokentext part. Omit it on the first upload and the server mints one (a 128-bit random value) and returns it; send that same value back on every further upload of the same submission to group the files. - Max body 11 MB (server edge cap); the file itself is capped at 10 MB (see the store
limits above). All the file-content, type, quota and form-availability rules from
Accepted files / Rejected above apply here — the file's real type is verified from
its magic bytes and the declared
Content-Type/filename are treated as hostile.
Response (200): a JSON object with the submission_token, the stored file_id, and
the server-derived filename, content_type and size_bytes (the sanitized name and
magic-sniffed type actually persisted):
{ "submission_token": "…", "file_id": "…", "filename": "report.pdf",
"content_type": "application/pdf", "size_bytes": 20481 }
What is rejected (fail-closed, 4xx/5xx, JSON {"error":"<code>"}):
| Condition | Status |
|---|---|
Not POST |
405 |
| Unknown/disabled/unpublished form, or the workflow's workspace feature is off, or a malformed path token | 404 (uniform — no oracle) |
Content type not multipart/form-data, or no boundary |
415 |
| A file whose magic bytes are not on the allow-list, or don't match the declared type/extension | 415 |
Empty body, malformed multipart, no file part, more than one file part, a bad/duplicate submission_token part |
400 |
| File over 10 MB, or the submission exceeds 25 MB total | 413 |
| The submission already holds 5 files | 409 |
| Malware detected by the scan (when scanning is enabled) | 422 malware_detected |
| The malware scanner is unavailable (fail-closed) | 503 + Retry-After scan_unavailable |
| Too many uploads (dedicated per-IP or per-form rate limit) | 429 |
| Server/store fault | 500 |
There is no CAPTCHA on this upload endpoint (the file is uploaded before the form's CAPTCHA is answered); the dedicated per-IP and per-form upload rate limits, the per-submission quota, the magic-byte allow-list and the 24-hour retention are the anti-abuse controls on this leg.
Retention. An uploaded file lives for 24 hours and is then deleted, whether or not it was used. When a run consumes a file it is deleted immediately (single-use, no post-run retention). Deleting the form removes its pending uploads too.
Referencing an uploaded file on submit
The public form page uploads each attached file to the endpoint above before the form
is submitted, then the final urlencoded POST /forms/{token} carries the selection so the
run can claim it:
- each file field carries its returned
file_idas the value under the field's key (<key>=<file_id>); - one hidden
__aig_submissionparameter carries the sharedsubmission_tokenthat claims those files.
The server resolves each file_id against the store from its authoritative stored metadata
and builds the bytes-free file reference. What is rejected at submit (fail-closed, 4xx):
| Condition | Status |
|---|---|
| A required file field with no uploaded file | 400 |
A file_id that is not a live upload under the submitted submission_token (unknown, expired, already-consumed, or forged) |
400 |
A file_id bound to a different form than the one being submitted |
400 |
A missing or malformed __aig_submission token |
400 |
The same file_id reused across two file fields |
400 |
The stored (magic-sniffed) type not in the field's x-accept |
415 |
The stored size over the field's x-max-size |
413 |
These checks re-enforce the field's x-accept/x-max-size narrowing on the authoritative
server-derived type and size (the browser's client-side check is only a convenience
mirror, never the authz boundary), and bind each file to the form it was uploaded to —
so a file uploaded to one form can never be referenced into another form's run (which would
otherwise resolve the live PII fence against the wrong gateway).
PII. Uploaded content inherits the platform's PII protection at run execution — identical to the form's text fields: when the run feeds a file's content to an agent step, the guardrail masking runs live against the form's gateway before anything reaches a model, and the workflow delivery egress fence blocks external delivery of a run that touched personal data.
How a run consumes an uploaded file
A submitted file does not travel through the workflow run as bytes. On submission the
form records a small, bytes-free file reference as the value of the file field inside
the run's trigger_input — a store handle plus metadata (content_type, size,
name), never the file content (run input is size-bounded). At the very start of the
run the trigger node consumes each reference: it fetches the bytes from the anonymous
store, extracts them to text with the same document extractor the chat file-analysis path
uses (a .docx yields structure-preserving Markdown — headings, lists, pipe tables — with a
graceful fall back to flat text for an enormous or unreadable document), applies the live
PII fence, and replaces the reference with the resulting text —
so {{<trigger>.output.<field>}} yields the file's (masked) text to downstream agent
steps, and the reference (including its single-use claim token) never reaches a model.
The reference is untrusted and validated fail-closed at consumption; a reference that is malformed, points at a missing / expired / already-consumed file, or resolves to an unknown or disabled form fails the run closed (it never proceeds with a raw reference or empty content). A file reference is honoured only for form-triggered runs — a webhook or manually-triggered run cannot drive an anonymous-store consume.
The live PII fence is re-evaluated at consumption from the form's gateway's effective
configuration — the same folded, cached configuration the inference path uses (the tenant's
mandatory-masking flag included; coherent within an instance on every admin write, bounded by
config_cache_ttl across instances, see the config reference)
— never a verdict stored at upload time (which could go stale if the gateway's PII policy
changes within the file's 24-hour life). Persisted content is a strict leg: nothing re-scans
the run state, the runner templates it into every later step and a Deliver step emits it, so the
tenant's PII mandate binds here exactly as it does on an outbound web-search query — a local-only
gateway's model-leg exemption does not apply:
- gateway not masking PII and no PII mandate on the tenant → the extracted text is used as-is;
- a PII mandate in force (tenant-enforced masking) but no reversible masker
(
pii_protector) on the gateway — a detector-less, custom-keyword-only or scrub-only gateway → the content is withheld behind the placeholder[content withheld: personal-data masking is mandatory for this tenant and this gateway has no reversible masker](fail closed; previously such a gateway persisted the content raw); - gateway masking PII with a fail-closed masker (a
pii_protectorwithfail_open: false) → the text is masked before it is stored in the run state (so no raw personal data is ever persisted in run history) — under a mandate at the mandate floor (PERSON/LOCATIONunioned into the detector's entity list at the German0.6bar whatever itslanguagesays), and a masking outage withholds the content behind the[tool result withheld: PII scan unavailable]placeholder rather than leaking it — under a mandate whatever the detector'sfail_opensays; - gateway masking PII but without a fail-closed masker for bulk content (custom keywords
only, scrub-only, or a
pii_protectorwithfail_open: true— which counts as no fail-closed masker, so such a gateway is withheld unconditionally, not only on an outage) → the content is withheld behind[content withheld: this gateway masks personal data but has no fail-closed masker for bulk content](fail closed).
The extracted text is size-capped so one file cannot overflow a run step's output; an over-cap extraction is truncated (with an explicit notice), never silently dropped. A PDF attachment is likewise read up to a 200-page bound (raised from a previous silent 20-page limit), and if pages beyond that were dropped the consumed text carries an explicit "only the first N of M pages were extracted" notice. A scanned PDF (no text layer) is read via OCR, and the consumed text is labelled "Read via OCR — may contain transcription errors; verify specific figures/dates against the source" (the same low-confidence caveat the chat and web-fetch paths apply), so a downstream agent qualifies OCR'd figures instead of treating them as authoritative.
Managing a form (any authoring user)
Form config uses the same gate as the other workflow triggers: the WORKFLOWS_AUTHOR
permission (the built-in member, ki_manager, tenant_admin, or admin roles, or a
custom role granting it; viewer/demouser are refused 403).
| Method | Path | Purpose |
|---|---|---|
GET |
/admin/v1/gateways/{gateway}/workflows/{id}/form |
Show the form (or { "form": false }) |
POST |
/admin/v1/gateways/{gateway}/workflows/{id}/form |
Create it (409 if one exists) |
PATCH |
/admin/v1/gateways/{gateway}/workflows/{id}/form |
enabled / captcha_enabled (strict booleans) or regenerate_token: true (a discrete action) |
DELETE |
/admin/v1/gateways/{gateway}/workflows/{id}/form |
Remove it + cancel the workflow's pending form runs |
Disabling, regenerating the token, or deleting the form cancels the workflow's
still-pending form runs and clears their submitted trigger_input — so a form is a
real kill switch and no submitter data lingers on a cancelled run.