Build a start form from the input schema
The schema option of defineWorkflow, the workflow’s input schema, decides
which parameters an instance accepts. IdentityFlow also derives a description of
the start form from it. In this recipe you give each field a label, offer a
fixed list of choices, and set limits that are checked before the first step
runs.
Save the two files as start-form.workflow.ts and
start-form.workflow.case.ts.
The host deploys only a workflow file’s default export, so one file holds one
workflow; the test imports the same definition by name.
import * as v from '@identity-flow/sdk/valibot';import { defineWorkflow } from '@identity-flow/sdk';
/** * The input schema is the start form. IdentityFlow derives the input contract * from it, and the deployed definition carries it, so a titled literal union * arrives as a list to choose from instead of a free-text field. */const Employee = v.union([ v.pipe(v.literal('e-2041'), v.title('Requesting employee')), v.pipe(v.literal('e-2042'), v.title('Line manager, vacation approvals')), v.pipe(v.literal('e-2043'), v.title('Deputy for the line manager')),]);
const VacationRequest = v.object({ employeeId: Employee, firstDay: v.pipe(v.string(), v.isoDate(), v.title('First day of leave')), days: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(30), v.title('Working days')), note: v.optional(v.pipe(v.string(), v.maxLength(280), v.title('Note for the approver'))),});
export const vacationRequest = defineWorkflow( { name: 'vacation-request', version: '1.0.0', draft: false, schema: VacationRequest }, async (flow) => { // Parameters are validated before the first step runs, so everything below // can read `flow.params` without checking it again. return await flow.do('record request', () => ({ employeeId: flow.params.employeeId, firstDay: flow.params.firstDay, days: flow.params.days, note: flow.params.note ?? null, })); },);
// The host deploys only the default export, so one file holds one workflow. The// test imports the named export; both are the same definition.export default vacationRequest;v is Valibot, the schema library @identity-flow/sdk
re-exports. v.pipe(schema, …) adds rules and metadata to a schema, similar to
tags on a Go struct field.
Three parts of the schema shape the form:
v.title()gives each field and each choice a plain-text label.- The literal union turns the
employeeIdfield into a list with exactly three entries. - The rules
isoDate,integer,minValue,maxValueandmaxLengthare checked before the first step runs. An instance with invalid parameters is not started, so the workflow body does not checkflow.paramsagain.
note is v.optional, so the form offers it and the workflow reads
undefined when it was left empty.
The web application renders the form on the workflow’s page in the
Workflow catalog (/workflows). Your own client reads the same description
as the inputContract field of startCatalog.find(id) in the
GraphQL API. IdentityFlow derives the description from
Valibot schemas only; with another schema library the field is null, and the
parameters are still validated.
A literal union fits a short list that changes together with the workflow, such as leave types or cost centers. Adding a choice is a new minor version. Removing one needs a new major version, because running instances validate their parameters again each time they continue; see Deploy a new version of a workflow. The three employees here are example data: a real employee picker needs a lookup in your own UI, not a literal list in the workflow.
import { workflowTest } from '@identity-flow/testing';import { expect } from 'vitest';
import { vacationRequest } from './start-form.workflow';
const test = workflowTest({ workflow: vacationRequest, accounts: { requester: { providerId: 'acme', principals: ['user:e-2041'] } },});
test('accepts what the start form can produce', async ({ flow, accounts }) => { const instance = await flow .actAs(accounts.requester) .start({ employeeId: 'e-2041', firstDay: '2026-07-06', days: 5 });
const completed = await flow.waitFor(instance).toBeCompleted();
expect(completed.data).toEqual({ employeeId: 'e-2041', firstDay: '2026-07-06', days: 5, note: null, });});
test('rejects parameters the start form cannot produce', async ({ flow, accounts }) => { await expect( flow.actAs(accounts.requester).start({ // @ts-expect-error The union offers three employees; this is not one of them. employeeId: 'e-9999', firstDay: '2026-07-06', days: 5, }), ).rejects.toThrow();
await expect( flow .actAs(accounts.requester) .start({ employeeId: 'e-2041', firstDay: '2026-07-06', days: 400 }), ).rejects.toThrow();});The first test starts an instance with parameters the form can produce and
checks the recorded result. The second checks two rules the form enforces: the
list of employees and the limit on days. The
@ts-expect-error comment tells TypeScript that the next line must be a type
error; the type check (npx tsc --noEmit) fails if the union no longer
rejects 'e-9999'. rejects.toThrow() shows that IdentityFlow refuses the
value at run time as well, so a client that bypasses TypeScript gets the same
answer. Vitest alone does not check types.
Run the test with the Workflow Test distribution, as described in Before you start.
Next: Author workflows for how validation fits the replay rules, and Assign a dialog to a role for a step that waits for a person.