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

json
{
  "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:created with kind: "form", then form:submitted when it settles.
  • The API. GET /forms/pending lists open pauses for the organization. GET /forms/:runId/:stepId returns one pause with its resolved schema and, once submitted, the submission.
  • The webhooks. form_pause_created when it opens, then form_submitted or form_expired.
bash
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:

  1. A schema attribute on the <hyphen-form> element, for previews.
  2. A schema authored for that step.
  3. A schema generated once for that workflow step, when the organization has the agent_forms feature. It is cached, so the model runs once per step, not once per run.
  4. 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

bash
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

bash
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

bash
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.

The generator applies Hyphen's form-authoring rules, validates the result, retries once when validation fails, and returns the deterministic fallback if the second attempt is still invalid.

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:

json
{ "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

html
<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

bash
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:

text
{ "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