Skip to content

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 employeeId field into a list with exactly three entries.
  • The rules isoDate, integer, minValue, maxValue and maxLength are checked before the first step runs. An instance with invalid parameters is not started, so the workflow body does not check flow.params again.

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.