Skip to content

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

GET https://ai-api.myra.eu/forms/{token}

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 type application/x-www-form-urlencoded only (a JSON or multipart body is rejected). Max body 64 KB.
  • One parameter per declared field, plus the hidden __aig_version binding 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
PDF 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 type multipart/form-data with a boundary.
  • Exactly one file part — the single part carrying a filename — per request (upload additional files with additional requests, reusing the same submission_token).
  • An optional submission_token text 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_id as the value under the field's key (<key>=<file_id>);
  • one hidden __aig_submission parameter carries the shared submission_token that 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_protector with fail_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 / LOCATION unioned into the detector's entity list at the German 0.6 bar whatever its language says), 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's fail_open says;
  • gateway masking PII but without a fail-closed masker for bulk content (custom keywords only, scrub-only, or a pii_protector with fail_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.