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

  1. Create the app

    npx --yes @agent-native/core@latest create my-forms --standalone --template forms
    cd my-forms
    pnpm install

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

  2. Start the dev server

    pnpm dev

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

Forms data model

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
Relations

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.

Patching a form's fields safely
templates/forms/actions/patch-form-fields.ts
1/**
2 * Granular field-level update for a form.
3 *
4 * Accepts a list of per-field operations (upsert / remove / reorder) and
5 * applies them server-side via read-modify-write against the CURRENT row, so
6 * concurrent edits to DIFFERENT fields both survive instead of the later
7 * client overwriting the earlier one with its stale full-array snapshot.
8 *
41 [LOCK_KEY]?: Map<string, Promise<unknown>>;
42};
43const globalRef = globalThis as GlobalWithLocks;
44if (!globalRef[LOCK_KEY]) {
45 globalRef[LOCK_KEY] = new Map<string, Promise<unknown>>();
46}
47const formLocks: Map<string, Promise<unknown>> = globalRef[LOCK_KEY]!;
48 
63 
64const fieldOpSchema = z.union([
65 z.object({
66 op: z.literal("upsert"),
67 field: z
68 .record(z.string(), z.any())
69 .describe(
70 "Complete field object with id, type, label, and required; never use shorthand strings.",
89 .union([z.string(), z.array(fieldOpSchema)])
90 .describe(
91 "Array of field ops, or JSON string of the same. Each op is {op:'upsert',field:{...}} | {op:'remove',id:string} | {op:'reorder',ids:string[]}",
92 ),
93 }),
94 run: async (args) => {
95 await assertAccess("form", args.id, "editor");
96 
105 if (!existing) {
106 throw new Error(`Form ${args.id} not found`);
107 }
108 
119 
120 if (!Array.isArray(ops)) {
121 throw new Error("ops must be an array");
122 }
123 
124 // Parse current fields from the DB row.
125 let currentFields: FormField[] = [];
126 try {
127 currentFields = normalizePersistedFields(
128 JSON.parse(existing.fields),
129 ) as FormField[];
130 } catch {
131 currentFields = [];
132 }
133 
134 // Apply ops server-side so concurrent edits on different fields both land.
135 const nextFields = applyFieldOps(
136 currentFields,
137 ops as Parameters<typeof applyFieldOps>[1],
138 );
139 
145 
146 const now = new Date().toISOString();
147 await db
148 .update(schema.forms)
149 .set({ fields: JSON.stringify(nextFields), updatedAt: now })
150 .where(eq(schema.forms.id, args.id));
151 
153 
154 return { id: args.id, fields: nextFields, updatedAt: now };
155 });
156 },
157});
Lines 49–62A per-form lock, not a database lock

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.

Lines 97–104The row is read inside the lock, not before 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.

Lines 109–118Ops can arrive as a JSON string or a real array

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.

Lines 140–144Validated before it's saved, twice if the form is live

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.

Line 152The public cache is invalidated on every write

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

Forms template layout
Forms template layout14 files
_app.forms._index.tsxthe forms list
_app.forms.$id.tsxthe form builder (Edit, Responses, Settings, Integrations tabs)
_app.forms.$id_.responses.tsxthe responses view for one form
_app.response-insights.tsxthe response insights page
_app.ask.tsxthe Ask Forms landing chat
f.$.tsxthe public fill page (canonical: /f/{slug})
form-preview.tsxinternal form preview, used by the builder

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_shares uses