# Form

> Create a form that collects data from users — field types, conditionals, multi-page, access modes, file uploads, calculated fields. Scaffolds the config, wires the handler, and deploys.

- **Type:** Skill
- **Install:** `agentstack add skill-mugwork-mug-form`
- **Verified:** Pending review
- **Seller:** [mugwork](https://agentstack.voostack.com/s/mugwork)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [mugwork](https://github.com/mugwork)
- **Source:** https://github.com/mugwork/mug/tree/main/.claude/skills/form
- **Website:** https://mug.work

## Install

```sh
agentstack add skill-mugwork-mug-form
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Create a Form

Build a form that collects data from users. Forms are JSON config files in `surfaces/` that deploy as live web pages at `https://.mug.work/`. Submissions trigger a handler workflow.

For full API reference (all field types, condition operators, page branching, access modes, edit mode), see `.mug/docs/forms.md`.

## Input

Form name or description: `$ARGUMENTS`

If no argument provided, ask the user what the form should collect. Clarify:
- What information do they need? (fields and their types)
- Who fills it out? (public, verified identity, or restricted access)
- What should happen when someone submits? (email notification, AI processing, store in database)

## Step 1 — Name and plan

Pick a kebab-case name for the form (e.g., `service-request`, `intake`, `feedback`).

Present a brief plan:
- Fields needed (with types from the catalog below)
- Access mode (public, identify, or auth)
- Post-submission actions
- Single page or multi-page

Wait for user confirmation.

## Step 2 — Create the form config

Run `mug form init ` to scaffold a starter form, then edit the config. The form config lives at `surfaces/.json`:

```json
{
  "type": "form",
  "title": "Service Request",
  "description": "Submit a new service request",
  "submitText": "Submit Request",
  "workflow": "handle-service-request",
  "access": { "mode": "public" },
  "fields": [
    { "name": "name", "type": "text", "label": "Your Name", "required": true },
    { "name": "email", "type": "email", "label": "Email", "required": true },
    { "name": "message", "type": "textarea", "label": "Message", "rows": 4 }
  ]
}
```

### Form config fields

- `type` — must be `"form"`
- `title` — form heading (required)
- `description` — subtitle text below heading
- `submitText` — submit button label (default: "Submit")
- `workflow` — name of the handler workflow that processes submissions (required)
- `access` — who can access the form (see Access Modes below)
- `fields` — shorthand for a single-page form (array of fields)
- `pages` — multi-page form (array of page objects, overrides `fields`)
- `editMode` — load and update existing records (see Edit Mode below)

## Step 3 — Write the submission handler workflow

When someone submits the form, a handler workflow runs. Submission data arrives as `ctx.params`. Create `workflows/handle-.ts`:

```typescript
import { workflow } from "@mugwork/mug";

workflow("handle-", async (ctx) => {
  const params = ctx.params as Record;
  const name = params.name;
  const email = params._verified_email ?? params.email;

  // Store in database
  await ctx.exec(`INSERT INTO submissions (name, email, message, created_at)
    VALUES (?, ?, ?, datetime('now'))`, [name, email, params.message]);

  // Send notification
  await ctx.notify.email({
    to: "owner@example.com",
    subject: `New submission from ${name}`,
    message: `Name: ${name}\nEmail: ${email}\nMessage: ${params.message}`,
  });

  return { processed: true };
});
```

### Submission data in ctx.params

- All field values are available by field name: `params.name`, `params.email`, etc.
- **File fields** contain R2 URL strings (e.g., `"https://r2.mug.work/workspace/uploads/abc123.jpg"`). Use `fetch()` in a workflow to download and process uploaded files.
- `params._verified_email` — present when access mode is `identify` or `auth` with email
- `params._verified_phone` — present when access mode is `identify` or `auth` with phone
- `params._surface` — the form's surface ID
- `params._workspace` — workspace name
- `params._edit` — `true` when the submission is an edit of an existing record
- `params._editRecord` — original record data (edit mode only)

For the full `ctx.params` shape across all trigger types (forms, portals, webhooks), see `.mug/docs/api.md`.

## Step 4 — Test

```bash
mug dev                    # start the dev server — form is live immediately
```

Open `http://localhost:8787/` in a browser to test. No need to run a workflow first — the form config is read directly from `surfaces/`.

For production:
```bash
mug deploy                 # deploy workspace — surfaces upload automatically
```

The production form lives at `https://.mug.work/`.

Workflows in `workflows/` are auto-discovered by `mug deploy` — no import needed.

## Step 5 — Validate (optional)

```bash
mug form validate          # check all form configs for errors
mug form list              # show forms and URLs
```

---

## Form Feature Catalog

Reference for all form capabilities. Use these when designing forms based on user requirements.

### Field Types

**Text fields** — single-line input:
```json
{ "name": "company", "type": "text", "label": "Company Name", "required": true, "placeholder": "Acme Inc." }
{ "name": "email", "type": "email", "label": "Email Address", "required": true }
{ "name": "phone", "type": "phone", "label": "Phone", "placeholder": "+1 555 123 4567" }
```
Add `pattern` for regex validation: `{ "type": "text", "pattern": "^[A-Z]{2}\\d{4}$" }`

**Number field** — numeric input with constraints:
```json
{ "name": "quantity", "type": "number", "label": "Quantity", "min": 1, "max": 100, "step": 1 }
```
Renders numeric keyboard on mobile. `min`, `max`, `step` are all optional.

**Select field** — dropdown:
```json
{
  "name": "urgency", "type": "select", "label": "Priority", "required": true,
  "options": [
    { "label": "Low", "value": "low" },
    { "label": "Medium", "value": "medium" },
    { "label": "High", "value": "high" }
  ]
}
```

**Multiselect field** — checkboxes (submitted as array):
```json
{
  "name": "services", "type": "multiselect", "label": "Services Needed",
  "options": [
    { "label": "Cleaning", "value": "cleaning" },
    { "label": "Repair", "value": "repair" },
    { "label": "Inspection", "value": "inspection" }
  ]
}
```

**Date field** — date picker:
```json
{ "name": "preferred_date", "type": "date", "label": "Preferred Date", "min": "2026-01-01", "max": "2026-12-31" }
```

**Textarea field** — multi-line text:
```json
{ "name": "notes", "type": "textarea", "label": "Additional Notes", "rows": 4, "maxLength": 2000 }
```
`rows` defaults to 3. `maxLength` is optional.

**File field** — file upload (stored in R2):
```json
{ "name": "photo", "type": "file", "label": "Upload Photo", "accept": "image/*", "maxSizeMb": 5 }
```
`accept` uses standard MIME types (`image/*`, `.pdf`, `application/pdf`). `maxSizeMb` defaults to 10. The submitted value is the R2 URL of the uploaded file.

**Calculated field** — auto-computed from other fields (read-only display):
```json
{ "name": "total", "type": "calculated", "label": "Estimated Total", "expression": "hours * rate", "format": "currency" }
```
`expression` references other field names. Supports `+`, `-`, `*`, `/`, `%`. Recalculates on every input change.
`format`: `"number"` (default), `"currency"` ($X.XX), `"percent"` (X.X%).

**Hidden field** — invisible, always submitted:
```json
{ "name": "form_version", "type": "hidden", "default": "v2" }
{ "name": "employee_id", "type": "hidden", "prefill": { "source": "auth", "column": "id" } }
```
Never rendered. Use for tracking params, form IDs, or auth-prefilled values the user shouldn't see.

### Default Values

Any field can have a `default` — pre-populated on load, lowest priority in the fill chain:
```json
{ "name": "status", "type": "hidden", "default": "pending" }
{ "name": "priority", "type": "select", "label": "Priority", "default": "medium", "options": [...] }
```

### Prefill

Auto-fill from three sources — auth row, URL params, or database:

**From auth row** (requires `auth` access mode — auto-fills from the user's table row):
```json
{ "name": "employee_name", "type": "text", "label": "Your Name",
  "prefill": { "source": "auth", "column": "name" }, "locked": true }
```

**From URL parameter:**
```json
{ "name": "department", "type": "text", "label": "Department",
  "prefill": { "source": "url", "param": "dept" } }
```

**From database record:**
```json
{ "name": "manager", "type": "text", "label": "Manager",
  "prefill": { "source": "db", "table": "departments", "column": "manager_name",
    "match": { "column": "id", "param": "dept_id" } }, "locked": true }
```

### Help Text

Display a hint below the field label. Supports `{{column}}` templates resolved from the auth row:
```json
{ "name": "hours", "type": "number", "label": "Hours Requested",
  "helpText": "You have {{available_pto_hours}} hours available" }
```

### Field Name Uniqueness

**Field names must be unique across the entire form** — including fields on different pages and conditional (`showWhen`) fields. Duplicate names cause silent data loss at submission. `mug form validate` and `mug dev` will error on duplicates.

### Validation Rules

Custom validation with user-facing error messages. Rules: `min`, `max`, `minLength`, `maxLength`, `pattern`. Values support `{{column}}` templates from the auth row:
```json
{ "name": "hours", "type": "number", "label": "Hours Requested", "required": true,
  "helpText": "You have {{available_pto_hours}} hours available",
  "validate": [
    { "rule": "min", "value": 0.5, "message": "Must request at least 30 minutes" },
    { "rule": "max", "value": "{{available_pto_hours}}", "message": "Cannot exceed your {{available_pto_hours}} available hours" }
  ]
}
```
Template values that resolve to numbers are auto-coerced for `min`/`max`. Validation runs client-side per-page.

### Locked Fields

`"locked": true` makes a field read-only + server-enforced. The submitted value is always the known source (auth row, default, etc.) regardless of HTML tampering:
```json
{ "name": "name", "type": "text", "label": "Your Name",
  "prefill": { "source": "auth", "column": "name" }, "locked": true }
```

### Conditional Fields

Show or hide any field based on other field values. Add `showWhen` to the field:

```json
{
  "name": "emergency_details", "type": "textarea", "label": "Describe the emergency",
  "required": true,
  "showWhen": [{ "field": "service_type", "op": "eq", "value": "emergency" }]
}
```

**Operators:** `eq`, `neq`, `in`, `gt`, `lt`, `filled`, `empty`

```json
showWhen: [{ "field": "category", "op": "in", "value": ["repair", "emergency"] }]
showWhen: [{ "field": "quantity", "op": "gt", "value": 10 }]
showWhen: [{ "field": "photo", "op": "filled" }]
```

Multiple conditions = AND logic (all must be true). Hidden fields are excluded from validation.

### Multi-Page Forms

Use the `pages` array instead of `fields` for multi-page forms:

```json
{
  "type": "form",
  "title": "Client Onboarding",
  "workflow": "handle-onboarding",
  "pages": [
    {
      "id": "contact",
      "title": "Contact Information",
      "fields": [
        { "name": "name", "type": "text", "label": "Name", "required": true },
        { "name": "email", "type": "email", "label": "Email", "required": true }
      ]
    },
    {
      "id": "details",
      "title": "Request Details",
      "fields": [
        { "name": "service_type", "type": "select", "label": "Service", "required": true, "options": [] },
        { "name": "notes", "type": "textarea", "label": "Notes" }
      ]
    }
  ]
}
```

The renderer auto-generates back/next buttons and a progress bar. Enter key advances pages. Validation runs per-page.

**Conditional pages** — show or hide entire pages:
```json
{ "id": "emergency-info", "title": "Emergency Details", "showWhen": [{ "field": "service_type", "op": "eq", "value": "emergency" }], "fields": [] }
```

### Page Branching

Route users to different pages based on their answers:

```json
{
  "id": "triage",
  "title": "What do you need?",
  "fields": [
    { "name": "request_type", "type": "select", "label": "Request Type", "required": true,
      "options": [
        { "label": "New Service", "value": "new" },
        { "label": "Emergency", "value": "emergency" }
      ]
    }
  ],
  "nextPage": {
    "conditions": [
      { "when": [{ "field": "request_type", "op": "eq", "value": "emergency" }], "goto": "urgent" }
    ],
    "default": "standard"
  }
}
```

Simple routing: `"nextPage": "details"` (string).

### Access Modes

Control who can access and submit the form.

**Choosing the right mode:**
- **`public`** — open form anyone can submit (contact us, feedback, public intake)
- **`identify`** — anyone can access after verifying email (self-service requests where you need to know who submitted)
- **`auth`** — only users in a database table can access (internal tools — employee forms, client-only portals)

**Rule of thumb:** if you have a table of who should access this form, use `auth`. If anyone with a valid email can submit, use `identify`.

**Public** — anyone can see and submit. No identity captured:
```json
"access": { "mode": "public" }
```

**Identify** — anyone can access after verifying email/phone:
```json
"access": { "mode": "identify", "method": "email", "sessionDuration": "7d" }
"access": { "mode": "identify", "method": "phone", "sessionDuration": "24h" }
```
Session duration format: `30m`, `24h`, `7d`. After verification, the user stays verified for this duration.

**Auth** — only users in a specific table can access the form:
```json
"access": {
  "mode": "auth",
  "method": "email",
  "table": "employees",
  "matchColumn": "email",
  "sessionDuration": "7d"
}
```

**Auth with computed columns** — use `query` to enrich the auth row with JOINs or calculations instead of a plain `SELECT *`. Use `:identity` as the placeholder for the authenticated user's email/phone:
```json
"access": {
  "mode": "auth",
  "method": "email",
  "table": "employees",
  "matchColumn": "email",
  "query": "SELECT e.*, r.hours_per_year - COALESCE(u.used, 0) AS available_pto FROM employees e LEFT JOIN pto_rules r ON r.employee_id = e.id LEFT JOIN (SELECT employee_id, SUM(hours) AS used FROM pto_requests WHERE status != 'denied' GROUP BY employee_id) u ON u.employee_id = e.id WHERE e.email = :identity",
  "sessionDuration": "7d"
}
```
Computed columns from `query` are available everywhere the auth row is used: `{{templates}}` in help text and validation, `prefill: { source: "auth" }`, and `ctx.params._auth_row` in workflows. Values are always fresh — no denormalized state to maintain. `table` and `matchColumn` are still required (used for dev-mode user dropdown).

In dev mode (`mug dev`), `auth` surfaces show a dropdown of registered users. `identify` surfaces show a freeform email input. Both bypass verification.

### Edit Mode

Load an existing record into the form for editing:
```json
"editMode": {
  "table": "service_requests",
  "recordParam": "id",
  "matchColumn": "id"
}
```
Edit URL: `https://.mug.work/?id=123`

### Prefill from URL Parameters

Any URL parameter that matches a field name pre-fills that field:
```
https://.mug.work/?name=John&email=john@example.com
```

### Breadcrumbs (cross-surface navigation)

When linking to a form from another surface (e.g., a portal), add `?from=` and `?fromLabel=` query params to show a "Back" breadcrumb above the form header:
```
/request-form?from=/employee-portal&fromLabel=Back to Portal
```
When the form is opened directly (no `?from=`), no breadcrumb appears. `fromLabel` is optional — defaults to "Back".

---

## Advanced: Dynamic Forms (ctx.collect)

For forms that need to be generated programmatically at runtime — when fields depend on database state, forms are generated per-user, or you're A/B testing form variations — use `ctx.collect()` inside a workflow instead of a static JSON config.

```typescript
import { workflow } from "@mugwork/mug";

workflow("create-dynamic-form", async (ctx) => {
  const url = await ctx.collect({
    id: "dynamic-intake",
    title: "Intake Form",
    workflow: "handle-intake",
    access: { mode: "public" },
    fields: [
      { name: "name", type: "text", label: "Name", required: true },
    ],
  });
  return { formUrl: url };
});
```

**Important:** Dynamic forms created via `ctx.collect()` require running the creation workflow before the form URL works

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [mugwork](https://github.com/mugwork)
- **Source:** [mugwork/mug](https://github.com/mugwork/mug)
- **License:** MIT
- **Homepage:** https://mug.work

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: flagged — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-mugwork-mug-form
- Seller: https://agentstack.voostack.com/s/mugwork
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
