Developer Guide
This page is for developers customizing or extending the Forms template. It covers the quick start, every action, the data model, the route surface, and how to add to it. See Forms for what the app does.
Quick start
Create the app
npx --yes @agent-native/core@latest create my-forms --standalone --template forms cd my-forms pnpm installThis needs Node.js 22.22 or later and pnpm installed first. See Getting started if you haven't installed either yet, or don't have an LLM connection (Builder.io, an Anthropic/OpenAI key, or local Ollama) set up.
Start the dev server
pnpm devThe app opens at
http://localhost:8084. If it doesn't open automatically, visit that URL in your browser.
For a workspace with Forms alongside other apps, run npx --yes @agent-native/core@latest create my-platform and pick Forms in the template list.
Exports need file storage configured. export-responses calls uploadFile(). Without a file storage provider connected (the File storage row on Settings → Organization → Infrastructure, or a custom provider registered), the action throws instead of silently failing. Connect one before testing exports locally.
Action reference
Every operation is a TypeScript file in templates/forms/actions/, auto-mounted at POST /_agent-native/actions/:name.
Form lifecycle
| Action | What it does |
|---|---|
create-form |
Create a draft or published form (title, description, fields, settings, slug, status). Returns an editor URL, plus a public response URL when created as published. |
get-form |
Get a single form by id with all fields and settings. Private settings (integrations, allowedOrigins) are only included for owner, editor, or admin roles. |
list-forms |
List accessible forms with response counts. Pass archived: true to list soft-deleted forms (the Archive) instead of the main list. |
delete-form |
Soft-delete (sets deleted_at). Responses are preserved and stay visible in the Archive. Pass purge: true to permanently delete the form and all its responses. |
restore-form |
Restore a soft-deleted form. Responses stay intact. |
Editing fields
| Action | What it does |
|---|---|
patch-form-fields |
The visual builder's real edit path. Applies granular upsert / remove / reorder operations via a server-side, per-form-locked read-modify-write, so two concurrent editors patching different fields both survive instead of one clobbering the other. |
update-form |
Whole-array replace of fields, plus title / description / slug / settings / status. Marked legacy in its own source docblock, kept for agents and bulk imports that want to replace the entire form in one call. The UI builder does not use it for field edits. |
The result is validated before it's persisted. assertValidFields rejects invalid or duplicate field ids and conditional rules that reference a later or missing field. If the form is already published, assertPublishableForm also re-checks that every field still has a label and that select / multiselect / radio fields still have options.
Previewing and analyzing
| Action | What it does |
|---|---|
preview-form |
Inline chat summary of a form's setup: fields, status, visibility, response count, and an "Open editor" action. The first-party answer to "what's the setup for this form?" |
response-insights |
Chart, table, or insights analytics widget over response data. displayMode (chart / table / insights, default insights) controls what's returned. See Features for the full contract. |
list-responses |
List raw response rows for a form, for reasoning or export. If the user just wants to see responses in the UI, use navigate with view=responses instead of rendering rows in chat. |
export-responses |
Export responses to CSV or JSON, uploaded to configured file storage (never written to local disk), returning the file URL. Throws with a clear message if no file storage provider is configured. |
Database
| Action | What it does |
|---|---|
db-status |
Check the current PostgreSQL connection (DATABASE_URL). |
db-connect |
Explain how to configure DATABASE_URL for a deployment. It does not write the connection itself, since that is a deployment-level setting applied through your hosting provider. |
Navigation and UI state
| Action | What it does |
|---|---|
navigate |
Move the UI to a view, form, or tab. |
view-screen |
Read the current view back for the agent, including the current form, builder tab, and a preview of visible responses, scoped per browser tab. Not meant to be called directly by integrations. |
Data model
All data lives in SQL via Drizzle ORM. Schema: templates/forms/server/db/schema.ts. Forms carry the standard ownableColumns() and a matching framework shares table, so they slot into the per-user and per-org sharing model.
| Table | What it holds |
|---|---|
forms |
A form definition: title, description, unique slug, fields (JSON array of FormField), settings (JSON FormSettings), status (draft / published / closed), and a soft-delete deleted_at |
responses |
One submission per row: form_id, data (JSON { fieldId: value }), submitted_at, optional ip, submitter_email, page_url, and client_surface |
form_shares |
Framework shares table mapping principals (users or orgs) to roles (viewer, editor, admin) per form |
page_url and client_surface are hidden pass-through columns. Trusted embeds (like the framework's FeedbackButton) forward the URL of the page a respondent was on and the runtime shell they were in (web, electron, or tauri), so form owners can see which screen and which app feedback came from. Both are NULL for direct fills that send no context, and client_surface is allowlisted server-side. Anything else is dropped.
Every persisted field id (and conditional.fieldId) is restricted to /^[A-Za-z0-9_-]+$/. Both are interpolated into HTML attributes and inline-script selectors by the public-form renderer, so an unrestricted id would be a stored-XSS vector.
The fields and settings JSON shapes are defined in templates/forms/shared/types.ts (FormField, FormSettings). A field object looks like this:
{
"id": "team_size",
"type": "select",
"label": "Team size",
"placeholder": "Select...",
"description": "Roughly how many people are on your team?",
"required": true,
"options": ["1-5", "6-20", "21-100", "100+"],
"validation": {
"min": 1,
"max": 100,
"pattern": "^[0-9]+$",
"message": "Enter a number"
},
"width": "half"
}id, type, label, and required are the only required properties. options is required for select, multiselect, and radio. validation (min / max / pattern / message) is optional and type-appropriate. width is "full" (default) or "half" for a side-by-side layout.
A conditional field adds a conditional object referencing an earlier field:
{
"id": "other_use_case",
"type": "text",
"label": "Tell us more",
"required": false,
"conditional": {
"fieldId": "use_case",
"operator": "equals",
"value": "Other"
}
}fieldId must reference a field that appears earlier in the form. A forward or missing reference is rejected before it can be saved. The supported operators are equals, not_equals, and contains (the last one also matches against multi-select arrays).
FormSettings includes integrations (webhook URLs) and allowedOrigins, both owner-private. toPublicFormSettings() projects settings down to an explicit allowlist (submitText, successMessage, redirectUrl, showProgressBar) before any data reaches the public fill page, so integration URLs never leak to anonymous respondents.
A Google Sheets destination has to be a deployed Apps Script /exec URL that calls JSON.parse(e.postData.contents) and appends the values. A plain spreadsheet URL, or the script's /dev URL, will not receive submissions, since neither one runs the deployed script against POSTed data. Every integration and allowedOrigins URL is validated in server/lib/integrations.ts against private IP ranges, cloud-metadata addresses, and non-http(s) schemes, both when it's saved and again each time a response fires, so a form can't be turned into a way to probe your internal network.
A form definition (ownable)
id | id | PK |
title | string | |
description | string | nullable |
slug | string | unique; public URL |
fields | json | FormField[] — all field types |
settings | json | FormSettings — integrations, etc. |
status | enum | draft | published | closed |
deleted_at | datetime | nullable soft delete |
owner_email | string | |
org_id | id | nullable |
Three tables. Fields and integrations are JSON columns on forms, so the agent's edits are surgical patches rather than cross-table row changes.
An action walked through
patch-form-fields shows patterns worth reusing in any action: serializing concurrent writers to the same row, reading fresh state inside that lock instead of before it, and validating the result before it's persisted.
Keeps a Map of in-flight promises keyed by form id, and chains each new call after whatever is already running for that form. Edits to different forms run fully in parallel. Two edits to the same form run one after another, so the second one always sees the first one's result instead of racing it.
Reading happens after the lock is acquired, so it always reads the latest committed state. If the read happened first and the lock second, two overlapping calls could both start from the same stale row and the second write would silently erase the first.
A CLI call passes --ops as a string, so it's parsed here if needed. A programmatic caller can pass the array directly. Either way, the rest of the function only ever deals with a real array.
assertValidFields always runs and rejects duplicate or malformed field ids. assertPublishableForm only runs if the form is already published, so a live form can't be patched into a broken state, like removing the last option from a select field.
The public fill page caches a form for up to 60 seconds. Without this call, a field edit could take up to a minute to show up for anyone filling out the live form.
Routes reference
Customizing it
Ask the agent for shipped behavior first:
- "Add a required radio field for preferred contact method."
- "Post every new submission to Slack." Connect Slack first via Messaging.
- "Add a webhook destination for our CRM."
- "Create a customer feedback form with a 1-10 scale and a long-text follow-up."
- "Make some forms public and others login-only."
If you need new capabilities such as file uploads, signatures, or custom field widgets, treat them as template extensions. Add the SQL shape, actions, UI editor controls, public renderer support, and agent instructions together. See Creating Templates for the current build pattern.
What's next
- Forms — the overview and what it replaces
- Features — the user-facing behavior these actions and this data model power
- Talk to the Agent — every prompt by task and how the agent sees your screen
- Forms in a Multi-App Workspace — how these actions get exposed to other apps and Dispatch
- Actions — the action system this template is built on
- Sharing — the share-grant model
form_sharesuses