Forms, end to end
Time: 45 minutes
What you'll learn: the two kinds of form Hyphen has, when to use each, every field type the renderer supports, how a paused run collects data and resumes, how an intake form starts work, and how to share, embed, brand, and observe both.
Base URL used below: https://your-hyphen.example.com for engine calls with X-Org-Id, and https://<gateway> for the hosted page and the SDK.
Two kinds of form
PbotForm step |
External form | |
|---|---|---|
| What it is | A workflow step. The run pauses until a person supplies named keys | A standalone form with a schema and a trigger |
| When it appears | Mid-run, when execution reaches the step | Any time. It has a public link |
| What submitting does | Resumes that one run, once | Starts a new run with the submission as input |
| Who fills it in | Someone with a signed-in SDK session, or your backend | Anyone with the link, or a signed-in person when you require it |
| Where it shows up | The task list, Process Studio, or <hyphen-form run-id step-id> |
The hosted page, <hyphen-form form-id>, or a launcher button |
| Routes | /forms/... |
/external-forms/... and /forms/public/... |
One renderer serves both, so the schema and the components are the same. The rest of this guide takes them in turn.
Part 1: The PbotForm step
Step 1: Add the step
{
"type": "PbotForm",
"properties": {
"expected_keys": ["po_number", "corrected_amount", "reason"],
"ttl_seconds": 172800,
"reminder_intervals": [3600, 86400]
}
}
| Property | Type | Required | Meaning |
|---|---|---|---|
expected_keys |
string[] | Yes | The keys a submission must carry. The submit is rejected without them |
ttl_seconds |
number | No | How long the pause stays open. On expiry, the form closes, form_expired fires, and the run advances without submission data. Zero or absent means it does not expire |
reminder_intervals |
number[] | No | Seconds after the pause opens at which reminder webhook events fire, one per entry, for your application to send the follow-up |
When the run reaches the step, it moves to paused. Nothing else happens until a person submits, or the pause expires.
Step 2: See the pause
Three views of the same pause:
- The event stream.
task:createdwithkind: "form", thenform:submittedwhen it settles. - The API.
GET /forms/pendinglists open pauses for the organization.GET /forms/:runId/:stepIdreturns one pause with its resolved schema and, once submitted, the submission. - The webhooks.
form_pause_createdwhen it opens, thenform_submittedorform_expired.
curl https://your-hyphen.example.com/forms/pending -H "X-Org-Id: acme-corp"
Step 3: Give it real fields
expected_keys alone produces one text field per key. The renderer resolves fields in this order and stops at the first that exists:
- A
schemaattribute on the<hyphen-form>element, for previews. - A schema authored for that step.
- A schema generated once for that workflow step, when the organization has the
agent_formsfeature. It is cached, so the model runs once per step, not once per run. - One text field per expected key.
The schema is the same form_schema_v1 document an external form uses; see The schema below. Whatever produced the fields, every expected key must be present as a field key.
Step 4: Submit and resume
curl -X POST https://your-hyphen.example.com/forms/run-…/2/submit \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{ "data": { "po_number": "PO-4471", "corrected_amount": 1250.00, "reason": "Freight added after receipt" } }'
The engine checks the expected keys, records who submitted, resumes the run, and refuses a second submit with 409. The submitted values are added directly to the run context: each key becomes @key, so the next step can read @corrected_amount and @reason.
Through the gateway the same call is POST /sdk/forms/run/:runId/:stepId/submit. It needs a signed-in session: an anonymous publishable-key session can read the pause but not settle it, and the gateway answers 403 with a hint.
Step 5: Put it in front of a person
<hyphen-task-sidebar>lists the pause beside approvals with Fill it in and renders the form inline.- Process Studio shows Fill it in on the paused case.
<hyphen-form run-id="run-…" step-id="2">renders it anywhere in your product.
Part 2: The external form
Step 1: Create it from a schema
curl -X POST https://your-hyphen.example.com/external-forms \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{
"name": "Vendor exception intake",
"description": "Tell us about the charge you are disputing.",
"is_public": true,
"expires_at": "2026-12-31T23:59:59Z",
"trigger_workflow_id": "wfl-…",
"schema": { "version": "form_schema_v1", "title": "Dispute a charge", "pages": [ … ] },
"settings": {
"requireAuth": false,
"successMessage": "Thanks. We will be in touch.",
"redirectUrl": "https://example.com/thanks"
}
}'
| Field | Meaning |
|---|---|
is_public |
Whether the hosted page and the public routes serve it without a session |
expires_at |
After this time the form no longer accepts submissions |
trigger_workflow_id |
Start this workflow on every submission, with the flat payload as input |
trigger_process_id |
Start this published Process Studio case instead. Never both |
settings.successMessage |
Shown after a successful submit |
settings.redirectUrl |
Where the hosted page sends the person after a submit, instead of the message |
settings.requireAuth |
Ask for an email and a one-time code before the form |
settings.branding |
White-label the hosted page: name, logoUrl (https), accent (hex), footer, hidePoweredBy |
The reply carries the form id. GET, PUT, and DELETE /external-forms/:id read, update, and remove it; GET /external-forms lists the organization's forms.
Step 2: Create it from a sentence
curl -X POST https://your-hyphen.example.com/forms/generate \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{
"intent": "Collect a vendor exception dispute: the charge, the disputed amount, why, and how to reach them. Two short pages.",
"expected_keys": ["amount_due", "reason", "contact_email"],
"hints": { "audience": "vendors, not staff", "tone": "plain and short" },
"create": { "name": "Vendor dispute intake", "is_public": true, "trigger_workflow_id": "wfl-…" }
}'
The reply is { schema, source, attempts, form }. source is llm or fallback; attempts is one or two. Without create, only the schema comes back for you to review and post yourself. The generator validates its output against the contract, retries once with the validator's errors, and falls back to one text field per expected key. hints is a free object; each entry becomes a line the model sees.
The organization needs the agent_forms feature. Without it the call answers 403 agent_forms_disabled. The generator offers the model ten field types: text, number, email, date, multiline, content, select, checklist, range, and money. It never asks for passwords, card numbers, or government id numbers unless a required key demands one.
Step 3: Share it
The public link is https://<gateway>/forms/<formId>. No account, no key. The page is noindex, served with no-store, and reads the form through the gateway's anonymous proxy, which never exposes the organization id and limits each visitor IP to 60 reads and 10 submits per form per minute. ?theme=dark or ?theme=light forces a theme.
Submitting answers 201:
{ "submission_id": "sub_…", "triggered_run_id": "run-…", "triggered_execution_type": "workflow_run" }
triggered_execution_type is workflow_run or agent_run. The organization's webhooks receive external_form_submitted with the same fields.
Know who answered. With settings.requireAuth: true, the page asks for an email, sends a one-time code, and submits with the resulting session. An unsigned submit is refused with 401 form_signin_required. The triggered run records submitted_by, and so does the webhook. The code is issued against the organization's publishable key, so no new credential exists.
Step 4: Embed it
<script src="https://<gateway>/sdk/v1.js"></script>
<script>
HyphenSDK.init({ baseUrl: 'https://<gateway>', publishableKey: 'pk_live_…' });
</script>
<!-- Inline, under your session -->
<hyphen-form form-id="frm-…" submit-text="Send" done-text="Received."></hyphen-form>
<!-- Inline, no session: the public routes -->
<hyphen-form form-id="frm-…" public public-base="https://<gateway>"></hyphen-form>
<!-- A button that opens the form in an overlay -->
<hyphen-form-button form-id="frm-…" public>Dispute a charge</hyphen-form-button>
<!-- Any element becomes a launcher -->
<a href="#" data-hyphen-form="frm-…" data-hyphen-form-public>Dispute a charge</a>
<!-- Navigate to the hosted page instead -->
<hyphen-form-button form-id="frm-…" href target="_blank">Open the form</hyphen-form-button>
A page with no publishable key at all can still render public forms: call HyphenSDK.defineFormElements() instead of init(). Attributes, events, and styling tokens are on the Forms page.
Step 5: Read the submissions
curl https://your-hyphen.example.com/external-forms/frm-…/submissions -H "X-Org-Id: acme-corp"
Each submission carries its payload, triggered_run_id, and submitted_by when sign-in was required. Follow the run with GET /runs/:runId/status and read its record with GET /runs/:runId/evidence.
The schema
Both kinds of form use form_schema_v1:
{ "version": "form_schema_v1", "title"?, "description"?,
"pages": [ { "id", "title"?, "description"?, "fields": [ Field ] } ] }
Field: { "key", "type", "label", "help"?, "placeholder"?, "required"?, "default"?,
"options"?, "min"?, "max"?, "step"?, "unit"?, "multiple"?, "masked"? }
Keys are snake_case and unique across the whole form, not just the page. The submitted payload is always flat, { key: value }, whatever the page structure.
Field types
| Type | Renders as | Uses | Submits |
|---|---|---|---|
text |
One-line input | placeholder, masked to hide what is typed |
string |
multiline |
Text area | placeholder |
string |
number |
Numeric input | min, max, step |
number |
money |
Amount input | min, max, step, unit for the currency label |
number |
range |
Slider | min, max, step, unit |
number |
email |
Email input with format check | string | |
date |
Date picker | ISO date string | |
select |
Drop-down | options as { label, value }; multiple for several |
value, or an array with multiple |
checklist |
Check boxes | options |
array of values |
content |
Guidance text; the label is the text | nothing | |
file, signature, table, lookup, computed |
A placeholder | nothing; never required |
required blocks the submit until the field has a value. default pre-fills it. help renders under the label.
The renderer also accepts three older shapes and normalizes them: the legacy builder's components array, a flat field list, and a JSON-Schema object. Author new forms as form_schema_v1.
Observe it
| Signal | PbotForm | External form |
|---|---|---|
| SDK events | task:created (kind: "form"), form:submitted |
hyphen-form:submitted on the element |
| Webhooks | form_pause_created, form_submitted, form_expired |
external_form_submitted |
| API | GET /forms/pending, GET /forms/:runId/:stepId |
GET /external-forms/:id/submissions |
Public page limits, per visitor IP per form per minute: 60 reads, 10 submits.
Choosing between them
- The run already exists and needs one more fact before it can continue: PbotForm.
- The form is how work begins: external form with a trigger.
- A person outside your organization must answer: an external form with
is_public, or a PbotForm rendered on a page where that person can sign in with a one-time code. - You want the fields written for you:
POST /forms/generate, for either kind.
What You Learned
- the PbotForm step, its three properties, and the pause it creates
- how a pause is listed, rendered, submitted, and resumed, and why settling one needs a signed-in session
- the external form, its settings, and its two triggers
- every field type the renderer supports and what each submits
- the hosted page, the components, the launcher, and the keyless page
- the events, webhooks, and routes to watch either kind
→ Next: Forms that start work, the 20-minute walkthrough