Install
$ agentstack add skill-mugwork-mug-form Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Dangerous shell/eval execution.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
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:
{
"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 headingsubmitText— 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, overridesfields)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:
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"). Usefetch()in a workflow to download and process uploaded files. params._verified_email— present when access mode isidentifyorauthwith emailparams._verified_phone— present when access mode isidentifyorauthwith phoneparams._surface— the form's surface IDparams._workspace— workspace nameparams._edit—truewhen the submission is an edit of an existing recordparams._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
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:
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)
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:
{ "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:
{ "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:
{
"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):
{
"name": "services", "type": "multiselect", "label": "Services Needed",
"options": [
{ "label": "Cleaning", "value": "cleaning" },
{ "label": "Repair", "value": "repair" },
{ "label": "Inspection", "value": "inspection" }
]
}
Date field — date picker:
{ "name": "preferred_date", "type": "date", "label": "Preferred Date", "min": "2026-01-01", "max": "2026-12-31" }
Textarea field — multi-line text:
{ "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):
{ "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):
{ "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:
{ "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:
{ "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):
{ "name": "employee_name", "type": "text", "label": "Your Name",
"prefill": { "source": "auth", "column": "name" }, "locked": true }
From URL parameter:
{ "name": "department", "type": "text", "label": "Department",
"prefill": { "source": "url", "param": "dept" } }
From database record:
{ "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:
{ "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:
{ "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:
{ "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:
{
"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
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:
{
"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:
{ "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:
{
"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:
"access": { "mode": "public" }
Identify — anyone can access after verifying email/phone:
"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:
"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:
"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:
"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.
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
- Source: mugwork/mug
- License: MIT
- Homepage: https://mug.work
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.