Skip to main content
View as Markdown

Install skill

Give your AI coding agent the applicaster-forms-api skill so it can build this integration on its own. Read it first to see exactly what it instructs your agent to do.

1. Install it

Run this in your terminal, from your project root:

npx skills add https://docs.applicaster.com --skill applicaster-forms-api

In the agent picker, select your agent.

2. Use it

In your agent, paste this prompt (or just ask it to build the integration using the installed skill):

Use the applicaster-forms-api skill to help me implement the Applicaster "Forms API" guide. Ask me about the requirements first, then build.

Forms API

Introduction

The Forms API powers data-entry screens — creating and editing a profile, updating account details, or any other screen where the user fills in values and saves them. The Form Screen plugin renders the screen from a JSON form configuration, prefills it from the feed entry the user selected, and posts the result to a customer-hosted endpoint as a CloudEvent.

Three artifacts take part, and all three must agree on field IDs:

ArtifactProvidesOwned by
Form configuration feedproperties[] — one entry per field. Defines what is rendered and how it validates on device.The form author, in Zapp
Entry feedextensions.form_data on the selected entry — the values the form opens with.The backend
Submission endpointReceives data.form_data, persists it, returns success or validation errors.The backend

A field ID appears in all four places: properties[].id, the prefilled form_data, the submitted data.form_data, and the fieldErrors keys of an error response. Rename it in one place and prefill, submission, or error display silently breaks.

Editable Entries Feed

The feed that lists the records the user can edit — profiles, for example. Two things make an entry editable:

  1. type — the entry type, typically "profile-edit". The value itself is a recommendation, not a requirement; what matters is that it is mapped to the Form screen in the app configuration. When the user taps the entry, the app resolves the type mapping, navigates to the Form screen, and passes the entry data to it.
  2. form_data extension — the entry's extensions object carries a form_data payload used to prefill the form.
{
"title": "Manage Profiles",
"entry": [
{
"id": "a3JVE000005wcej2AA",
"title": "Main Profile",
"type": {
"value": "profile-edit"
},
"extensions": {
"form_data": {
"profileName": "Main Profile",
"dob": "2024-12-31",
"parentalControls": ["allow_comment", "allow_change_picture"],
"kidsProfile": false,
"profileId": "a3JVE000005wcej2AA",
"email": "profile.user@example.com",
"profileImage": "char:1741"
},
"master": 1
}
}
]
}
note

Hidden fields. Not every key in form_data has to be editable. The form renders a component only for the fields declared in the form configuration. A key with no matching component — "profileId" above — is never shown, stays in the screen state, and is sent back unchanged on submit. This is the supported way to round-trip immutable server identifiers.

Creating a New Record

To create rather than edit, the app must reach the Form screen without form_data, or with an empty object. Two common ways:

  1. Inline with the editable entries feed — append a "New Profile" entry as the last element. It can share the same type mapping or use a different one, such as profile-add, as long as it resolves to the Form screen.
  2. As a separate manual feed — attach a feed to a distinct "Create New" button component on the list screen. In Zapp this is a Manual Feed.
{
"id": "69ef9a0d-5582-4b60-ae34-6a2fc01fbc2b",
"title": "Profile - Create New Button",
"type": {
"value": "feed"
},
"extensions": {},
"entry": [
{
"id": "e6df0cea-7305-433f-b466-1817bec74787",
"title": "New Profile",
"type": {
"value": "profile-add"
},
"extensions": {
"form_data": {}
}
}
]
}

Form Configuration

To set up the form in your app:

  1. Add the Form Screen plugin to your app in Zapp.
  2. Create a new screen of this type.
  3. In the screen configuration, open the Data section.
  4. Assign the form structure feed — the JSON described below — to the Form Feed selector.

The configuration is an object with a required properties array. Each entry defines one field: a text input, date picker, checkbox, selector, or button.

{
"id": "fieldId",
"type": "textInput | datePicker | checkBox | singleSelect | multiSelect | button | label",
"preset": "PresetName",
"options": {
"title": "Field title"
}
}

How It Works Internally

  1. State initialization — the screen copies the selected entry's extensions.form_data into its internal screen state. With no entry data, for example when creating, the state starts empty.
  2. Component instantiation — the screen parses properties and instantiates one UI component per field using the declared preset.
  3. Data binding — each component is bound to its value in the screen state. User input updates the state, and the state is what gets submitted.

Form Configuration Sample

{
"properties": [
{
"id": "profileName",
"type": "textInput",
"preset": "FormTextInput",
"options": {
"title": "Profile Name",
"required": true,
"minLength": 3,
"maxLength": 12
}
},
{
"id": "dob",
"type": "datePicker",
"preset": "FormTextInput",
"options": {
"title": "Date of Birth",
"required": true
}
},
{
"id": "parentalControls",
"type": "multiSelect",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Parental Controls",
"required": true,
"minItems": 1,
"maxItems": 3,
"items": [
{ "const": "allow_comment", "title": "Allow comments" },
{ "const": "allow_change_picture", "title": "Allow picture changes" },
{ "const": "allow_change_name", "title": "Allow name changes" }
]
}
},
{
"id": "kidsProfile",
"type": "checkBox",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Kids Profile"
}
},
{
"id": "buttonSave",
"type": "button",
"preset": "FormButtonSave",
"options": {
"title": "Save",
"extensions": {
"tap_actions": {
"actions": [
{ "type": "validateForm" },
{
"type": "postForm",
"options": {
"data": {
"form_data": "@{screen/}"
},
"inflateData": true,
"subject": "profile_update",
"type": "com.applicaster.post.data.v1",
"url": "https://example.com/events"
}
},
{
"type": "navigateToScreen",
"options": {
"typeMapping": "home-screen"
}
}
]
}
}
}
}
]
}
note

The save button above uses FormButtonSave, which the published formTypes.ts does not declare — it lists FormButton, FormButtonCancel and FormButtonDelete. Confirm the preset against your installed plugin version before copying this sample.

Field Types

Field typePresetNotes
textInputFormTextInputFree text input. Supports required, minLength, maxLength, secure, and numeric validation through options.inputType: "number".
datePickerFormTextInputDate entry field. The form contract uses type: "datePicker"; the current rendering path still uses FormTextInput and passes inputType: "date" to the text field component. Values are stored and submitted as ISO 8601 strings such as YYYY-MM-DD; date-time strings with an offset are also accepted by validation.
checkBoxFormMultiSelectGroupBoolean field. A required checkbox must be set to true. For server compatibility, tolerate both booleans and "true" / "false" strings in received payloads.
singleSelectUsually FormMultiSelectGroup, or a custom preset such as FormAvatarPickerOne selected value. Items can be provided inline or through itemsFeedURL.
multiSelectFormMultiSelectGroupMultiple selected values. Items can be provided inline or through itemsFeedURL. Server prefill and submit values are arrays of selected option IDs. Supports required, minItems, and maxItems.
buttonFormButton, FormButtonCancel, FormButtonDeleteExecutes an action chain. See Buttons.
label""Static text. Confirm support against your installed plugin version before using it.

Options

OptionApplies toNotes
titleAll visible fieldsDisplay title for the generated component or feed.
descriptionAll visible fieldsSecondary text.
requiredInputs, date picker, checkbox, selectorsValidated before submission when validateForm runs.
minLength, maxLengthText input and number inputLength constraints.
minItems, maxItemsMultiselectSelection-count constraints. Optional empty multiselect fields remain valid unless required is also set.
secureText inputSecure text entry.
inputTypeText inputstring, number, or date.
itemsSelect fieldsInline options, each { "const": "<id>", "title": "<label>" }.
itemsFeedURLSelect fieldsURL that provides selectable entries. Use instead of items, not alongside it.
extensions.tap_actionsButtonsAction chain executed by the button.

Date Picker

Use datePicker when the field collects a date. The submitted value is an ISO 8601 date such as 2000-05-31. The validator also accepts ISO date-time values, for example 2024-12-31T10:30:00Z or 2024-12-31T10:30:00+02:00.

Empty values are handled by required; an optional empty date field is valid. An invalid filled value shows the form validation error Please enter a valid date.

Until there is a dedicated date picker component, the screen renders this field through the text input component with date input behavior. Form authors should still use type: "datePicker" as the stable form contract.

Multiselect

Each option has a string const, and the value crossing the server boundary is an array of those const values.

{
"id": "parentalControls",
"type": "multiSelect",
"preset": "FormMultiSelectGroup",
"options": {
"title": "Parental Controls",
"items": [
{ "const": "allow_comment", "title": "Allow comments" },
{ "const": "allow_change_picture", "title": "Allow picture changes" }
],
"minItems": 1,
"maxItems": 2
}
}

Server prefill and the submitted payload both use the same shape:

{
"parentalControls": ["allow_comment", "allow_change_picture"]
}

If the user selects nothing, the submitted value is an empty array: "parentalControls": [].

caution

Avoid commas in multiselect option IDs. The screen state uses comma-delimited internal storage while the form is open, so a comma inside an option const splits into two selections at the server boundary. Use simple stable IDs such as allow_comment or allow_change_name.

Buttons

A button is type: "button" plus an action chain under extensions.tap_actions. The preset is the role and the styling; the behavior is entirely in the action chain — a delete-styled button with a save chain will save.

RolePresetChain
SaveFormButton (samples above use FormButtonSave)validateFormpostFormnavigateToScreen or goBack
CancelFormButtonCancelnavigateToScreen or goBack, optionally after confirmDialog
DeleteFormButtonDeleteconfirmDialogsendCloudEvent (or postForm) → navigateToScreen or goBack
note

goBack returns to the previous screen instead of pushing a new one, so a save or delete does not leave the user one level deeper in the stack every time. It is supported by the plugin but not yet declared in formTypes.ts; form-e2e accepts it, and its drift test will flag the day the vendored types catch up.

Available actions are validateForm, postForm, navigateToScreen, goBack, confirmDialog, and sendCloudEvent. Two rules govern every chain:

  • Actions run in order and the chain stops on failure. When postForm returns field errors, nothing after it runs. Anything that must not happen on a failed submit — navigation, a success dialog — belongs after postForm; anything that must happen first belongs before it.
  • Only postForm surfaces formError and fieldErrors. sendCloudEvent posts and moves on: it cannot show the user an error and it does not stop the chain. Use postForm wherever the server can reject the submission with a message the user needs to see — a save, above all. Use sendCloudEvent for a post with nothing to report back: a delete, and fire-and-forget side events such as analytics chained after a submission.

Cancel

No validateForm — a half-filled form is exactly what the user is abandoning — and no postForm. Add confirmDialog only if losing edits should be confirmed. All four dialog texts are required, and dismissing the dialog stops the chain.

{
"id": "buttonCancel",
"type": "button",
"preset": "FormButtonCancel",
"options": {
"title": "Cancel",
"extensions": {
"tap_actions": {
"actions": [
{
"type": "confirmDialog",
"options": {
"title": "Discard changes?",
"message": "Your edits will not be saved.",
"okButtonText": "Discard",
"cancelButtonText": "Keep editing"
}
},
{
"type": "navigateToScreen",
"options": { "typeMapping": "manage-profiles" }
}
]
}
}
}
}

Delete

confirmDialog first, because deletion is not undoable. Then sendCloudEvent: a delete has nothing to validate and nothing for the server to reject, so it does not need postForm's error surfacing. No validateForm either — the record is being removed, so a half-valid form must still be deletable.

Use postForm here instead if your endpoint can genuinely refuse a delete ("you cannot delete your last profile") and you want that message shown on the form. Mind the payload key when you switch: postForm sends the screen state as data.form_data, while sendCloudEvent sends the keys you declare under dataformTypes.ts declares form.

{
"id": "buttonDelete",
"type": "button",
"preset": "FormButtonDelete",
"options": {
"title": "Delete Profile",
"extensions": {
"tap_actions": {
"actions": [
{
"type": "confirmDialog",
"options": {
"title": "Delete this profile?",
"message": "This cannot be undone.",
"okButtonText": "Delete",
"cancelButtonText": "Cancel"
}
},
{
"type": "sendCloudEvent",
"options": {
"data": { "form": "@{screen/}" },
"inflateData": true,
"subject": "profile_delete",
"type": "com.applicaster.post.data.v1",
"url": "https://example.com/events"
}
},
{
"type": "navigateToScreen",
"options": { "typeMapping": "manage-profiles" }
}
]
}
}
}
}

The delete payload carries the same screen state as a save — the record identifier rides along as a hidden key. The server tells the two apart by subject, and should read the identifier from form_data rather than trusting an edited display name as the lookup key.

Server-Side Guide

The server participates in two directions: it passes initial values into the form through the selected entry's extensions.form_data, and it receives submitted values from the postForm CloudEvent under data.form_data.

Passing Data Into the Form

When editing an existing object, include a form_data object in the selected feed entry. Keys must match the form field IDs.

{
"extensions": {
"form_data": {
"profileId": "a3JVE000005wcej2AA",
"profileName": "Tester Test",
"email": "profile.user@example.com",
"displayName": "Big Test",
"gender": "Male",
"dob": "2000-05-31",
"profileImage": "char:1741",
"kidsProfile": false,
"parentalControls": [
"allow_comment",
"allow_change_picture",
"allow_change_name"
]
}
}
}
Field typeServer prefill shapeExample
textInputString, or another primitive that can be stringified"Tester Test"
datePickerISO 8601 string"2000-05-31"
singleSelectSelected option const string"char:1741"
multiSelectArray of selected option const strings["allow_comment", "allow_change_picture"]
checkBoxBoolean preferred; tolerate "true" / "false" strings when receiving submissionsfalse

For new objects, omit form_data or pass {}. Null and undefined values are not prefilled into visible fields.

Multiselect Server Contract

  • Send selected values as string[] in extensions.form_data.
  • Receive selected values as string[] in the submitted data.form_data.
  • Receive no selection as an empty array [], including untouched multiselect fields.
  • Do not send option objects as selected values. Option objects belong in the form configuration items or item feed, not in form_data.
  • Do not rely on comma-separated strings at the server boundary. Comma-separated storage is an internal screen-state detail only.

Correct:

{
"parentalControls": ["allow_comment", "allow_change_picture"]
}

Incorrect:

{
"parentalControls": [{ "const": "allow_comment", "title": "Allow comments" }]
}

Receiving Form Submissions

With inflateData: true, the app resolves @{screen/} — the whole screen state — before sending. The backend receives a CloudEvent envelope whose data.form_data contains the submitted values.

{
"specversion": "1.0",
"type": "com.applicaster.post.data.v1",
"datacontenttype": "application/json",
"subject": "profile_update",
"source": "aio://com.example.profileapp/versions/0.2.0-dev.1",
"time": "2026-09-02T20:44:11.103Z",
"data": {
"form_data": {
"profileName": "Tester Test",
"kidsProfile": "false",
"profileId": "a3JVE000005wcej2AA",
"profileImage": "char:1741",
"email": "profile.user@example.com",
"displayName": "Big Test",
"gender": "Male",
"dob": "2000-05-31",
"parentalControls": [
"allow_comment",
"allow_change_picture",
"allow_change_name"
]
}
},
"id": "26681f0c-796c-429b-bdab-176acba65f7c"
}

Read the form values from data.form_data. Treat the other CloudEvent fields as event metadata: type identifies the event kind, subject identifies the configured business action, source identifies the app and version, time records when the event was created, and id is the event identifier.

Normalize and validate the payload by field ID on the server. Important submitted shapes are:

Field typeSubmitted shape
textInputString
datePickerISO 8601 string
singleSelectSelected option const string
multiSelectArray of selected option const strings; empty selection is []
checkBoxBoolean when normalized from a declared checkbox field, but backends should also tolerate "true" and "false" strings from legacy flows or hidden values
caution

Client-side required, minLength, and minItems are validated by validateForm on device as a user-experience affordance, not as enforcement. Re-check every constraint on the server.

Returning Server Validation Errors

If server validation fails, return a failed HTTP response whose body contains formError, fieldErrors, or both. fieldErrors must be keyed by form field IDs, not by UI component IDs.

{
"status": 400,
"formError": "Profile could not be saved.",
"fieldErrors": {
"profileName": ["This profile name is already used."],
"dob": ["Date of birth must be in the past."],
"parentalControls": ["Select at least one parental control."]
}
}

The form presents formError as a form-level message and presents fieldErrors inline on the matching fields. When the server returns field errors, the form submission action is cancelled so later actions in the button chain do not continue.

caution

The failure has to arrive as a failed HTTP status. A 200 carrying an error body is read as success, and the rest of the button's action chain — including navigation away from the form — runs anyway.

Backend Checklist

  • Keep field IDs stable across form configuration, prefilled form_data, submitted payloads, and fieldErrors.
  • Send multiselect selected values as arrays of option IDs.
  • Treat missing optional values and empty arrays intentionally; [] means the user has no selected multiselect options.
  • Store date values as ISO 8601 strings.
  • Tolerate checkbox booleans and "true" / "false" strings when reading submitted payloads.
  • Decide explicitly what to do with form_data keys you do not recognize. An allowlist of writable field IDs is the safe default: hidden identifiers still round-trip because you read them without writing them blindly.
  • Return validation errors with fieldErrors[fieldId] = string[] and a failed HTTP status.
  • Preserve hidden identifiers by including them in the initial form_data when they must come back on submit.
  • Give each business action its own subject so one endpoint can serve save, delete, and any other write.
  • Profiles Feed — the feed that lists profiles for the picker screen.
  • Cloud Events — envelope handling for the endpoint that receives submissions.