Forms
Forms let you attach structured JSON Schema-driven workflows directly to cards. Each attachment becomes its own tab in the card editor, is validated by the SDK, and can be submitted from any interface. Submitted data is persisted per-form on the card and fires a form.submit webhook event.
- Define reusable workspace forms in
.kanban.jsonunder theformskey - Attach named or inline forms in card YAML frontmatter
- Full JSON Schema validation before persistence
${path}placeholder interpolation against card metadata at runtime- Submit from UI, CLI (
kl form submit), REST API, SDK, or MCP
After a successful submission, Kanban Lite also appends a card log entry containing the submitted payload under payload.
Card Forms
Card forms let a card carry one or more structured data-entry tabs backed by JSON Schema and rendered with JSON Forms.
They are designed for workflows where free-form markdown is not enough: incident reports, release checklists, QA sign-off, deployment approvals, intake forms, and other repeatable data capture.
This guide explains the feature end-to-end:
- where forms are declared
- how forms are attached to cards
- how form IDs and labels are resolved
- how initial values are merged
- how validation works
- how data is persisted
- how submissions emit the
form.submitwebhook event - how to work with the feature via the UI, CLI, REST API, and MCP
At a glance
A card form has three conceptual layers:
-
Definition
- A reusable workspace form in
.kanban.jsonunderforms.<name> - or an inline form definition attached directly to a card
- A reusable workspace form in
-
Attachment
- A card declares which forms it uses via
formsin card frontmatter
- A card declares which forms it uses via
-
State
- Submitted values are persisted per card under
formData[formId]
- Submitted values are persisted per card under
At runtime, Kanban Lite resolves each attachment into a normalized form descriptor with:
- a stable
id - a display
label - a resolved JSON Schema
- an optional JSON Forms UI schema
- merged
initialData - a
fromConfigflag
What problem card forms solve
Markdown is great for narrative text, but many workflows need structured fields:
- required values
- typed inputs
- repeatable checklists
- layout hints
- validation before submission
- machine-readable payloads for automation
Card forms add this structure without removing the existing markdown workflow. A single card can still contain:
- markdown content
- comments
- logs
- actions
- and now one or more form tabs
The fixed card tabs remain:
writepreviewcommentslogs
Each attached form contributes an additional dynamic tab using the route format:
form:<resolved-form-id>
Architecture overview
flowchart LR
A[.kanban.json\nforms.<name>] --> D[Resolved form descriptor]
B[Card frontmatter\nforms[]] --> D
C[Card frontmatter\nformData] --> D
E[Card metadata] --> D
D --> F[Dynamic card tab]
F --> G[JSON Forms renderer]
G --> H[Validated submit]
H --> I[Persist formData[formId]]
I --> J[Emit form.submit]
Core concepts
Workspace form definitions
Reusable forms live in .kanban.json under the root-level forms object.
These forms are workspace-scoped. They are surfaced through board info at runtime, but the source of truth is the workspace config, not an individual board.
Each reusable form is a FormDefinition with:
schema: required JSON Schema objectui: optional JSON Forms UI schemadata: optional default values
Example:
{
"forms": {
"incident-report": {
"schema": {
"type": "object",
"title": "Incident Report",
"required": ["severity", "summary"],
"properties": {
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"summary": {
"type": "string",
"minLength": 1
},
"owner": {
"type": "string"
}
}
},
"ui": {
"type": "VerticalLayout",
"elements": [
{ "type": "Control", "scope": "#/properties/severity" },
{ "type": "Control", "scope": "#/properties/summary", "options": { "multi": true } },
{ "type": "Control", "scope": "#/properties/owner" }
]
},
"data": {
"severity": "medium"
}
}
}
}
Card form attachments
A card opts into forms through the forms array in its frontmatter.
Each item is a CardFormAttachment.
Supported shapes:
1. Named reusable form reference
forms:
- name: incident-report
2. String shorthand for a named form
String shorthand is supported and normalizes to { name: "..." }.
forms:
- incident-report
- "qa-signoff"
3. Inline card-local form
forms:
- schema:
type: object
title: Release Checklist
required:
- owner
properties:
owner:
type: string
approved:
type: boolean
ui:
type: VerticalLayout
elements:
- type: Control
scope: "#/properties/owner"
- type: Control
scope: "#/properties/approved"
data:
approved: false
4. Named form with attachment-level overrides
When both name and inline fields are present, the attachment uses the named config form as a base and overrides parts of it.
forms:
- name: incident-report
data:
severity: high
Important resolution rules:
- Either
nameorschemamust be present. - If both are present, the attachment’s inline
schemawins over the config schema. - Attachment-level
uiwins over configui. - Attachment-level
datais merged after config-leveldata.
Per-card persisted form data
Submitted values are stored on the card in formData, keyed by resolved form ID.
formData:
incident-report:
severity: critical
summary: API outage in eu-west
owner: alice
This isolation is deliberate: two forms on one card can both contain a field named status without colliding, because persistence is namespaced by form ID.
Runtime resolution model
Kanban Lite does not render raw attachments directly. It first resolves them into a normalized runtime shape.
A resolved form descriptor contains:
idnamedescriptionlabelschemaui(optional)initialDatafromConfig
Resolved form ID rules
Resolved IDs matter because they control:
- tab routes
- persistence keys in
formData - submission targets (
formId)
Resolution rules:
Named reusable form
If the attachment references a reusable form by name, the resolved ID is the form name.
Example:
- attachment:
{ name: "incident-report" } - resolved ID:
incident-report
Inline form
If the form is inline only:
- Kanban Lite tries to slugify
schema.title - if there is no usable title, it falls back to
form-<index>
Examples:
title: "Release Checklist"→release-checklist- no title on the second inline form →
form-1
Duplicate inline titles
If multiple inline forms resolve to the same base ID, later ones receive a numeric suffix.
Example:
- first:
release-checklist - second:
release-checklist-2
Form labels
Labels shown in the card UI are resolved as follows:
- named reusable form: uses
KanbanConfig.forms[formKey].name - named reusable form without
name: defaults to a capitalized form key (incident-report→Incident Report) - inline form: uses
schema.title - fallback: a humanized resolved
id
Form descriptions
- reusable workspace forms may define
description - when omitted, the description defaults to an empty string
- inline forms currently resolve to an empty description
When a form is renderable
A form tab is only created when the attachment can resolve to a schema.
That means:
- inline attachment with
schema→ renderable - named attachment whose config form exists and has
schema→ renderable - named attachment with missing config form → not renderable
Kanban Lite does not rely on JSON Forms schema generation. The app expects a real schema to exist before a form becomes a usable tab.
Initial data merge order
The resolved form descriptor includes initialData, which is the prefilled state shown when the form tab opens.
Current merge order, from lowest to highest priority:
- workspace config defaults:
KanbanConfig.forms[formName].data - attachment defaults:
attachment.data - persisted card form data:
card.formData[formId](may be partial at rest) - matching card metadata fields:
card.metadata[key], only for keys declared inschema.properties
Sources 1–3 are subject to ${path} placeholder interpolation against the card context before being merged.
See Placeholder interpolation in form data below for supported
placeholder fields and the missing-value rule.
In other words, metadata can intentionally overlay earlier values, but only for schema-declared fields.
Metadata overlay behavior
Metadata is not copied wholesale into the form.
Only metadata keys that exist in the form schema’s properties are merged.
If a card contains:
metadata:
owner: alice
ignored: true
and the schema properties are only:
ownerseverity
then only owner is merged into initialData. ignored is skipped.
Placeholder interpolation in form data
Before sources are merged, Kanban Lite prepares string values in config defaults, attachment defaults, and
persisted formData entries by resolving ${path} placeholders against the full card context.
Supported context fields:
| Placeholder | Resolved from |
|---|---|
${id} |
Card identifier |
${boardId} |
Parent board identifier |
${status} |
Current column/status |
${priority} |
Priority level (critical, high, medium, low) |
${assignee} |
Assignee name, or empty string if unassigned |
${dueDate} |
ISO 8601 due date, or empty string if none |
${created} |
ISO 8601 creation timestamp |
${modified} |
ISO 8601 last-modified timestamp |
${completedAt} |
ISO 8601 completion timestamp, or empty string if not completed |
${order} |
Fractional index controlling sort order within a column |
${content} |
Markdown body content of the card |
${labels} |
Comma-separated label list (array coerced to string) |
${attachments} |
Comma-separated attachment filename list (not filesystem paths) |
${metadata.key} |
Any card metadata value using dot-notation (e.g. ${metadata.owner}) |
filePath is intentionally excluded to prevent filesystem paths from leaking into stored or rendered form data values. actions, forms, and formData are available for advanced dot-notation access but rarely needed in string templates.
Missing-value rule: placeholders whose paths do not resolve (missing or undefined keys) are replaced with
an empty string. This keeps form fields blank rather than showing a raw ${path} fragment.
Security note: placeholder resolution is a pure path-lookup over the card context object — no JavaScript expressions are evaluated and arbitrary code execution is not possible.
Interpolation scope: Sources 1–3 in the merge order below (config defaults, attachment defaults, persisted form data) are interpolated before the merge. The metadata overlay (source 4) is applied after, and always wins for schema-declared keys.
Interpolation example
Given a config form with:
{
"forms": {
"incident-report": {
"data": {
"owner": "${assignee}",
"region": "${metadata.region}",
"cardRef": "${id}"
}
}
}
}
And a card:
assignee: alice
metadata:
region: eu-west
The prepared config defaults become:
{
"owner": "alice",
"region": "eu-west",
"cardRef": "card-123"
}
A placeholder with no matching key (e.g. ${metadata.missing}) resolves to "".
Example initial-data resolution
Given:
- config defaults:
severity: lowtitle: Config Title
- attachment defaults:
severity: medium
- persisted form data:
severity: high
- metadata:
title: Metadata Title
The resulting initialData is:
{
"title": "Metadata Title",
"severity": "high"
}
Why?
- config seeds the base
- attachment overrides config
- persisted form data overrides attachment
- metadata overlays schema-matching keys last
Submission merge and validation
Submitting a form is not just “send the current visible fields.” The SDK builds and validates the canonical payload.
Submission merge order
When a submission is triggered, the SDK starts from the resolved initialData and merges the submitted payload on top.
Effective order, from lowest to highest priority:
- config form defaults
- attachment defaults
- persisted card form data
- schema-matching card metadata
- submitted payload
Why validation is authoritative in the SDK
The UI performs immediate validation for good UX, but the SDK is the source of truth.
The SDK:
- resolves the target form
- merges the final payload
- validates against the resolved schema
- persists only if validation succeeds
- emits
form.submitonly after a successful write
This guarantees identical rules across:
- webview UI
- standalone browser UI
- CLI
- REST API
- MCP
Validation engine
Kanban Lite uses AJV through JSON Forms’ createAjv helper with:
allErrors: truestrict: false
In the card form tab, JSON Forms is rendered with:
validationMode="ValidateAndShow"
That means the UI both validates and shows validation errors while editing.
UI validation behavior
The card form tab:
- validates the initial payload immediately
- disables the Submit button if validation errors exist
- renders a summary block listing validation issues
- formats missing-property style messages into clearer text
Example displayed state:
- header text:
Fix 1 validation error before submitting. - validation panel:
Validation issues - submit button: disabled until valid
SDK validation failure behavior
If validation fails in the SDK:
- persistence does not occur
form.submitis not emitted- callers receive an error
Persistence model
Successful submissions are written back to the card under:
formData:
<resolved-form-id>: { ...validatedPayload }
This means:
- each form gets its own persisted object
- future tab opens use that object as part of initial data resolution
- cards can safely attach multiple forms with overlapping field names
Example persisted card frontmatter
---
version: "1"
id: "230"
status: "backlog"
priority: "medium"
assignee: null
dueDate: null
created: "2026-03-20T00:00:00.000Z"
modified: "2026-03-20T00:10:00.000Z"
completedAt: null
labels: []
attachments: []
order: "a0"
metadata:
reporter: alice
forms:
- incident-report
- schema:
type: object
title: Release Checklist
properties:
approved:
type: boolean
formData:
incident-report:
severity: high
summary: API outage
reporter: alice
release-checklist:
approved: true
---
# Investigate outage
UI behavior
Dynamic card tabs
The card editor always includes the fixed tabs:
writepreviewcommentslogs
Attached forms add dynamic tabs using form:<id>.
Examples:
form:incident-reportform:release-checklistform:release-checklist-2
Deep links and internal tab state preserve these dynamic IDs so the correct form tab stays selected when possible.
Shared badge
When a resolved form comes from workspace config, the card form tab shows a Shared badge.
This indicates the form is backed by a reusable definition rather than existing only inline on that card.
Tab labels
Attached form tabs display the resolved form name with a form: prefix.
Examples:
form: Bug Reportform: Release Checklist
Inside the tab body, the header shows the resolved form name and, when present, the form description.
JSON Forms renderer set
Kanban Lite renders forms with:
@jsonforms/react@jsonforms/vanilla-renderers
The rendered form is wrapped in a .card-jsonforms container so theme-aware CSS can style the vanilla renderer output consistently inside the app.
This scoped styling covers layout, labels, controls, validation messages, arrays, groups, and focus states.
Success and error feedback
After submit:
- a successful save shows a confirmation message like
Saved Incident Report - transport or SDK failures show an error message block
- validation issues remain visible until resolved
- a system card log entry is appended with
formId,formName, and the submittedpayload
Standalone offline behavior
In standalone mode, form submission fails immediately while disconnected.
Instead of leaving the UI in an indefinite pending state, the frontend receives a transport error:
You are offline. Form submissions are unavailable until the standalone backend reconnects.
This behavior is specific to the standalone/browser transport path.
Submit flow
sequenceDiagram
participant U as User
participant T as Card form tab
participant H as Host transport
participant S as SDK
participant W as Webhook pipeline
U->>T: Edit fields
T->>T: Validate with JSON Forms/AJV
U->>T: Click Submit
T->>H: submitForm(cardId, formId, data, callbackKey)
H->>S: submitForm(input)
S->>S: Resolve form + merge payload
S->>S: Validate schema
alt valid
S->>S: Persist card.formData[formId]
S->>W: Emit form.submit
S-->>H: SubmitFormResult
H-->>T: submitFormResult(result)
else invalid
S-->>H: Error
H-->>T: submitFormResult(error)
end
Webhook behavior
Successful form submissions emit the SDK event type:
form.submit
The emitted payload includes:
boardId- sanitized
cardsnapshot - resolved
formdescriptor - canonical validated
data
Event payload shape
Conceptually:
{
"boardId": "default",
"card": {
"id": "230",
"status": "backlog",
"forms": [{ "name": "incident-report" }],
"formData": {
"incident-report": {
"severity": "high",
"summary": "API outage"
}
}
},
"form": {
"id": "incident-report",
"label": "incident-report",
"schema": { "type": "object", "properties": { "severity": { "type": "string" } } },
"initialData": {
"severity": "medium"
},
"fromConfig": true
},
"data": {
"severity": "high",
"summary": "API outage"
}
}
Important webhook guarantee
form.submit is emitted after persistence succeeds.
That means the webhook consumer sees the same payload that was actually stored on the card.
Authoring reusable forms in .kanban.json
Smallest valid reusable form
{
"forms": {
"checklist": {
"schema": {
"type": "object",
"properties": {
"done": { "type": "boolean" }
}
}
}
}
}
Reusable form with layout and defaults
{
"forms": {
"qa-signoff": {
"schema": {
"type": "object",
"title": "QA Sign-off",
"required": ["owner", "result"],
"properties": {
"owner": { "type": "string" },
"result": {
"type": "string",
"enum": ["pass", "fail", "blocked"]
},
"notes": { "type": "string" }
}
},
"ui": {
"type": "VerticalLayout",
"elements": [
{ "type": "Control", "scope": "#/properties/owner" },
{ "type": "Control", "scope": "#/properties/result" },
{ "type": "Control", "scope": "#/properties/notes", "options": { "multi": true } }
]
},
"data": {
"result": "blocked"
}
}
}
}
UI schema notes
JSON Forms UI schema is optional. If omitted, JSON Forms can generate a default UI layout from the JSON Schema.
Kanban Lite passes the resolved ui directly to JSON Forms, so common JSON Forms UI schema features are available, including:
- controls
- layouts
- rules
- renderer-specific
options
JSON Forms documentation for UI schema concepts:
- controls
- layouts
- rules
Attaching forms to cards
Reusable form by name
forms:
- incident-report
Multiple reusable forms
forms:
- incident-report
- qa-signoff
Inline form on a single card
forms:
- schema:
type: object
title: Approval
required:
- approver
properties:
approver:
type: string
notes:
type: string
Reusable form with card-specific defaults
forms:
- name: incident-report
data:
severity: critical
Reusable form with a card-local schema override
forms:
- name: incident-report
schema:
type: object
title: Incident Report Override
properties:
severity:
type: string
region:
type: string
When you override the schema inline, that schema becomes authoritative for validation and rendering on that card.
Working with metadata-driven prefills
Card metadata can seed form fields, but only if the schema explicitly declares those field names.
Example card:
metadata:
reporter: alice
region: eu-west
internalOnly: true
forms:
- name: incident-report
If the form schema defines reporter and region but not internalOnly, then the resolved initial data includes:
reporter: aliceregion: eu-west
and excludes:
internalOnly
This makes metadata useful for:
- inheriting card context into forms
- avoiding duplicate data entry
- keeping automation fields machine-readable
CLI usage
Create a card with attached forms
kl add --title "Investigate outage" --forms '[{"name":"incident-report"}]'
Update form attachments on an existing card
kl edit investigate-outage --forms @forms.json
Seed persisted form data
kl edit investigate-outage --form-data @form-data.json
Submit a form
kl form submit investigate-outage incident-report --data '{"severity":"high","owner":"alice"}'
CLI behavior notes
--formsexpects a JSON array--form-dataexpects a JSON object keyed by resolved form IDform submitrequires both<card-id>and<form-id>--datais required forform submitand must parse to a JSON object
When successful, the CLI prints either:
- a success message, or
- the full JSON result when
--jsonis used
REST API usage
Create a card with forms
POST /api/boards/:boardId/tasks
Example body:
{
"content": "# Investigate outage",
"forms": [
{ "name": "incident-report" }
],
"formData": {
"incident-report": {
"severity": "high"
}
}
}
Update a card’s forms
PUT /api/boards/:boardId/tasks/:id
Example body:
{
"forms": [
{ "name": "incident-report" },
{
"schema": {
"type": "object",
"title": "Release Checklist",
"properties": {
"approved": { "type": "boolean" }
}
}
}
]
}
Submit a form
POST /api/boards/:boardId/tasks/:id/forms/:formId/submit
Example body:
{
"data": {
"severity": "critical",
"summary": "Database unavailable"
}
}
Success returns the same canonical structure as the SDK submit result:
boardIdcardformdata
Validation or lookup failures return an error response.
MCP usage
The MCP server exposes forms through the same SDK contract.
Create a card with forms
Tool: create_card
Relevant fields:
formsformData
Update a card’s form attachments or persisted form data
Tool: update_card
Relevant fields:
formsformData
Submit a form
Tool: submit_card_form
Parameters:
cardIdformIddata- optional
boardId
The tool resolves partial card IDs using the same card lookup conventions as other MCP card tools.
JSON Forms behavior used by Kanban Lite
Kanban Lite currently relies on the JSON Forms standalone React component and passes:
schemauischemadatarendererscellsajvvalidationModeonChange
Practical implications
- The JSON Schema controls validation and base field semantics.
- The UI schema controls layout and presentation hints.
onChangeemits both the updated data and the latest validation result.- JSON Forms performs initial validation immediately, even before the user edits fields.
- The app uses vanilla renderers, then themes them with scoped CSS.
What Kanban Lite adds on top of JSON Forms
JSON Forms handles rendering and frontend validation, but Kanban Lite adds the application-specific behavior:
- card attachment resolution
- merge order for defaults, persisted state, and metadata
- stable per-card form IDs
- form tab routing
- persistence into card frontmatter
- SDK-authoritative validation before write
form.submitevent emission- transport between webview and host runtime
Best practices
Prefer reusable forms for shared workflows
Use .kanban.json forms for workflows that appear on many cards:
- incident reports
- QA sign-off
- launch checklists
- deployment approvals
Benefits:
- one source of truth
- consistent schema
- consistent default layout
- easier automation downstream
Use inline forms for card-specific structure
Inline forms are best when the structure is unique to a single card or a one-off experiment.
Keep resolved IDs stable
If you rely on persisted formData or automation by formId, avoid changing:
- reusable form names
- inline schema titles used to generate IDs
Changing either may produce a different resolved form ID and therefore a different formData key.
Use metadata intentionally
If a value already exists as card metadata and should appear in the form, define a matching schema property so the metadata overlay can prefill it.
Treat form names as API surface
For reusable forms, the name becomes:
- the resolved ID
- the persistence key
- the CLI/API/MCP
formId - the webhook context identifier
Choose stable, automation-friendly names such as:
incident-reportqa-signoffrelease-checklist
Common pitfalls
The form tab does not appear
Check the following:
- The card has a
formsarray. - Each item is either:
- a string shorthand form name,
- an object with
name, - or an object with
schema.
- Named forms exist in
.kanban.jsonunderforms. - The named config form has a valid
schema.
If no schema can be resolved, Kanban Lite will skip the dynamic form tab.
Submission fails even though the UI looked valid
The SDK is authoritative. A caller can still receive a validation error if:
- the submitted payload differs from the last visible UI state
- the target card or form cannot be resolved
- the schema rejects the merged payload after defaults/metadata are applied
Persisted data seems to “win” over config defaults
That is by design. card.formData[formId] is merged after config defaults and attachment defaults.
Metadata is not prefilling a field
That usually means the metadata key is not declared in schema.properties.
Inline form IDs changed unexpectedly
Inline forms derive IDs from schema.title when present. Renaming the title can change the resolved ID.
Reference summary
Reusable config form
Location:
.kanban.jsonforms.<name>
Shape:
{
schema: Record<string, unknown>
ui?: Record<string, unknown>
data?: Record<string, unknown>
}
Card attachment
Location:
- card frontmatter
forms[]
Shape:
{
name?: string
schema?: Record<string, unknown>
ui?: Record<string, unknown>
data?: Record<string, unknown>
}
String shorthand is also supported for named forms:
forms:
- incident-report
Persisted card form data
Location:
- card frontmatter
formData[resolvedFormId]
Shape:
Record<string, Record<string, unknown>>
Note: Persisted entries may be partial. At runtime Kanban Lite prepares the full canonical
initialDataby merging all four sources described above. The stored object is never required to contain every schema field.
Submit result
Shape:
{
boardId: string
card: Omit<Card, 'filePath'>
form: ResolvedFormDescriptor
data: Record<string, unknown>
}
Recommended workflow
For most teams, this is the cleanest pattern:
- Define reusable schemas in
.kanban.jsonunderforms - Attach forms to cards by name
- Use metadata for cross-cutting context you want prefilled
- Let the form persist into
formData - Trigger automation from the
form.submitevent
This gives you:
- human-readable markdown cards
- structured machine-readable submissions
- consistent validation
- UI/CLI/API/MCP parity
- deterministic webhook payloads
Source-of-truth notes
This document reflects the current implementation in:
- shared form and transport types
- SDK form resolution and submission flow
- parser and serializer behavior
- webview dynamic tab generation
- standalone and VS Code host transport handling
If behavior changes in the future, prefer the implementation and its tests as the final authority.