Parameters & Types¶
The fields a flow builder fills in on your block - a Movie ID to type, a Release Year that only takes a
number, a Genre picked from a list - and the checking that stops bad input before it reaches your code all
come from one declaration: params, a zod object. Pick a type and the editor draws the matching control
under the block's Parameters section; chain a plugin and the field gets its label, hint and default.
params: z.object({
movieId: z.string().label('Movie ID').describe('TMDB numeric id, e.g. 693134'),
year : z.number().optional().label('Release Year'),
}),
execute receives exactly what you declared, parsed and typed:
- values converted to the declared type, so a number arrives as a number
- undeclared fields stripped
- a blank field resolved the way you declared it - see Leaving a field blank
The container is always a z.object. Leave params out for an action that takes no input. A bare
shape, a single field, or a z.object().optional() stops the service from loading: npx flowrunner deploy
skips that service with a Skipping "<service>" warning that reads
`params` must be a z.object({ … }) — received … and deploys the rest, and a test sees
FR_EXT_INVALID_SCHEMA. Mark the fields optional, never the container.
What each type renders as¶
| zod type | The flow builder gets |
|---|---|
z.string() |
a single-line field |
z.text() |
a multi-line box |
z.number() |
a numeric field |
z.boolean() |
a toggle |
z.date() |
a date picker |
z.enum([...]) |
a dropdown of the values |
z.array(inner) |
a list. Entries that are groups, dropdowns or dictionary fields open as rows with add and remove controls; a list of plain strings or numbers opens as one expression box, and the view toggle beside the label switches it to rows |
z.object({...}) |
a nested group holding its own fields |
All eight, on one block:
The editor prints the published param type beside each label, which is why Date and Enum read as
[string]: the type is string, the widget is what differs. What a date field actually hands your handler is
under What a date field hands your handler.
The switch beside a label (tooltip: Toggle expression input) turns that control into an expression input
when you switch it off, so a flow builder can bind the value from an earlier block. A z.string() field has
no switch because it already is one - which is why the String row above shows a wand and no toggle. Here
Release Year is switched off and Minimum Rating is not:
A list or a group has no switch. The icons beside its label switch it between rows (tooltip: Use list of inputs) and one expression input for the whole value (tooltip: Use single input).
Other types. A few more zod types are accepted, each drawn as the control for the type it publishes:
z.union([...])- the first branch's control. A value is also converted by trying the branches in the order you wrote them, and the first that fits wins: given'false',z.union([z.boolean(), z.string()])hands your handlerfalse, butz.union([z.string(), z.boolean()])hands it the string'false', which is truthy. Put first the type an ambiguous value should become, and a catch-all such asz.string()last.z.record(k, v)- a nested group with no fixed fields, because its keys are open.z.literal(v)- the control forv's type, accepting onlyv:z.literal(true)is a toggle that has to be switched on, for an "I confirm" field.z.json()- a multi-line box, likez.text(), whose JSON your handler receives already parsed:42arrives as a number. Text that does not parse fails with«<label>» must be valid JSON. Do not callJSON.parseon it.z.any()andz.unknown()- a text field; the value reaches your handler unconverted.z.null()- the same text field, but it accepts only a blank: any typed value, evennull, fails with«<label>» must be null.
Modifiers such as .optional(), .default(v), .readonly() and .pipe() publish as the type underneath:
z.string().pipe(z.coerce.number()) is a text field whose value arrives as a number.
What the deploy refuses¶
z.tuple(), z.intersection() and the .catch(v) modifier stop the service from loading, so
npx flowrunner deploy skips that service with a message that names the field, and deploys the rest (see
Troubleshooting). Use these instead:
- for a tuple, a
z.object()with named fields, or az.array()when the entries share a type - for an intersection, one
z.object()holding all the fields - for
.catch(v),.default(v): it fills in a missing value and still reports a wrong one, where.catch(v)would swap a wrong value for the fallback without telling anyone
A service already deployed with one of these fails when its action runs, with the same message, until you
fix it and deploy again. While a deploy skips a service, the version deployed before stays live, so check the
deploy output for a Skipping "<service>" warning.
Giving a field its label, hint and default¶
Chain these onto any field:
| Plugin | Applies to | Effect |
|---|---|---|
.label(text) |
any field | The name shown on the block. Without one the key is humanized - releaseYear shows as Release Year. |
.describe(text) |
any field | The help text behind the ? icon beside the label. |
.optional() / .nullable() |
any field | Marks the field not required. What a blank arrives as is under Leaving a field blank. |
.default(v) |
any field | The value used when the field is left blank. Takes a value, never a label. |
.example(v) |
a result written as a zod schema |
Publishes v as the sample a flow builder binds against. On a param it does not create a sample to bind against. |
.dictionary(ref) |
a param | Fills the field from a dictionary, as a picker. See Dictionaries. |
.labels({ value: 'Label' }) |
z.enum |
Shows the label in the dropdown, stores and hands your handler the value. |
.secret() |
a z.string() config field |
Masks the input on the configuration form. On a param it does nothing, on purpose: a param's value is saved in the flow and readable over the API, so a credential belongs in config, never in a block field. See Service Structure. |
Sharing a param across actions. Declare it once, name it with a Param suffix, and put each plugin
call on its own line so a change touches one line:
const genreIdParam = z.string()
.label('Genre')
.dictionary(getGenresDictionary)
.describe('Only include movies in this genre')
Leaving a field blank¶
Every field is required unless you say otherwise. The editor sends an untouched field as an explicit null,
and the modifier on the declaration decides what your handler receives:
| Declared | A blank field arrives as |
|---|---|
| nothing | the block fails with «Genre» is required |
.optional() |
undefined |
.nullable() |
null |
.nullish() |
null |
.default(v) |
v |
So .optional() is the ordinary way to mark a field the flow builder may leave alone, and a .default() fills
in a field left untouched or cleared. Discover Movies declares one required field and
three the flow builder can skip:
params: z.object({
genreId : genreIdParam, // required
year : z.number().optional().label('Release Year'),
minRating: z.number().min(0).max(10).optional().label('Minimum Rating').describe('TMDB score out of 10'),
sortBy : z.enum(['popularity.desc', 'primary_release_date.desc', 'vote_average.desc'])
.default('popularity.desc')
.label('Sort By'),
}),
On the block, the required Genre is flagged until it is filled, the two numbers are empty numeric fields, and Sort By is an empty dropdown:
With a genre picked and the other three left blank, the run succeeds: the handler reads params.sortBy and gets popularity.desc. The declared
default is published with the block but not pre-filled into the field, so the flow builder cannot see it;
say what it is in the hint, .describe('Defaults to Most popular').
Do not pair .nullable() with .default(): the editor's null is handed over as null and the default
never fires. .optional().default(v) behaves as plain .default(v).
One exception is deliberate: on a z.string() field an empty string is a value, so a required text field
accepts ''. Add .min(1) to a string that must not be empty. The same '' reaches a format check, so an
optional z.email() or z.url() that a flow builder cleared is reported as invalid. Where blank must mean
not set on a format field, declare z.union([z.url(), z.literal('')]).optional(): the first branch draws
the URL field, and the second lets a cleared field through.
What is checked before your handler runs¶
For an action's params and a dictionary's criteria, everything you declare on a field is enforced before
execute runs: .min() and .max(), .int(), .regex(), the string formats such as z.email() and
z.url(), enum membership, and your own message on any of them. A violation fails the block, and the
message names the field by its label:
[flow-extension:tmdb] invalid params for method "discoverMovies" — minRating: «Minimum Rating» must be at most 10
That line is what the Test Monitor's Block Results tab shows after run block in the block's Test Panel, and what the block's error exit carries in a run:
Every offending field is reported in that one line. A failure inside a group or a list names the property,
not the container - «Person» is required, «Genres item 2» is required - and a message you wrote
yourself, such as z.string().min(3, 'Too short'), is shown as written.
Before a run, the editor flags only a required field left empty. Every other rule - a limit, a format, your
own .refine() - is checked when the block runs: typing 11 into Minimum Rating shows no warning until then.
Showing a label, sending a value¶
An API's own vocabulary is rarely what you want on a block. .labels() keeps the two apart: the dropdown
shows the label, the flow stores the value, and execute receives the value.
sortBy: z.enum(['popularity.desc', 'primary_release_date.desc', 'vote_average.desc'])
.labels({
'popularity.desc' : 'Most popular',
'primary_release_date.desc': 'Newest first',
'vote_average.desc' : 'Highest rated',
})
.default('popularity.desc')
.label('Sort By'),
The flow builder picks Highest rated; your handler gets vote_average.desc. Because the flow stores the
value, renaming a label later changes nothing that was saved. This holds at every depth - inside a
z.object(), inside a z.array(), inside a row of a list of groups.
.labels() is for a list you know when you write the service. When the options come from the API - genres,
projects, folders - use a dictionary.
.map() no longer exists
Earlier versions offered .map({ Label: value }), which took its argument the other way round. A service
still calling it fails to load, with a message naming .labels(). Move the API values into the z.enum
list and the display names into .labels().
What a date field hands your handler¶
Declare z.coerce.date() and your handler gets a Date whether the value was picked, typed or bound. Plain
z.date() hands over what was sent - an ISO string when the value was typed or bound, epoch milliseconds
from the picker - so its inferred Date type is wrong at run time. Both are validated as a date: not-a-date fails
with «Released After» must be a date, and .min() / .max() bounds are enforced.
The ISO formats reshape the input to the declared form: z.iso.date() given a full datetime hands over the
date part, z.iso.time() the time part. They render as plain text fields, with no picker.
Nested fields and lists¶
z.object() nests fields, and z.array() makes a list of them. Staying with TMDB:
params: z.object({
genres : z.array(genreIdParam).label('Genres'),
releaseWindow: z.object({
from: z.coerce.date().label('From'),
to : z.coerce.date().label('To'),
}).label('Release Window'),
}),
Because each genres entry is a dictionary field, the list opens as rows: + adds an entry, and the × beside
an entry removes it. A list of plain strings opens as one expression input until you switch it to rows. The
group shows its own fields, each with a date picker here:
A failure inside either names the entry - «Genres item 2» is required, «From» must be a date.
A list of groups is the common shape - line items, recipients, filters:
params: z.object({
castFilters: z.array(z.object({
personId: z.string().label('Person').dictionary(getPeopleDictionary),
role : z.enum(['cast', 'crew']).label('Role'),
})).label('Cast Filters'),
}),
Each entry opens as a card holding the group's fields:
Keep nesting to two levels: give an entry plain fields, never another list. A list inside a list gets a
single box per row, and what is typed there arrives as text, so the block fails with a message such as
«Keyword Groups item 1» must be an array.
Designing fields a flow builder can use¶
The person configuring your block is not the person who wrote it, and often does not know the API at all.
- Label anything whose key would read badly. A humanized key covers
releaseYear; it does not covertvIdorpg. - Back id-like fields with a dictionary.
Science Fictionin a dropdown beats knowing that the genre id is878. - Describe the format when there is one.
.describe('TMDB numeric id, e.g. 693134')tells the flow builder what to type. - Declare optionality honestly. A field marked required that the API treats as optional makes the block unusable for the case the API supports.
- Put limits in the declaration.
z.number().min(0).max(10)is checked before the request goes out, with a message that names the field; a check insideexecuteis not. - Order fields the way they are filled in. The identifier first, then filters, then anything rarely touched.
On a trigger. A trigger's params render under Payload on the trigger block, as plain fields with no expression switch, and a group or a list declared there renders as a single text field. Keep trigger params to the primitive types. See Triggers.
Fields that depend on a choice¶
Sometimes the right fields depend on what the flow builder picked first. Searching TMDB for a movie takes a
release year; searching for a TV show takes a first-air year. dynamicParams loads those fields from the
choice:
ext.addAction({
category : 'Discover',
id : 'discoverTitles',
label : 'Discover Titles',
description: 'Finds movies or TV shows by year.',
params : z.object({
mediaType: z.enum(['movie', 'tv']).labels({ movie: 'Movie', tv: 'TV show' }).label('Media Type'),
}),
dynamicParams: {
dependsOn: ['mediaType'],
execute : async ({ criteria }) => {
if (criteria.mediaType === 'tv') {
return { firstAirYear: z.number().optional().label('First Air Year') }
}
return { releaseYear: z.number().optional().label('Release Year') }
},
},
// ...
})
| Property | Type | What it does |
|---|---|---|
dependsOn |
string[] | The declared params the loaded fields depend on. |
execute |
function | ({ criteria, config, context, apiRequest, … }) => fields. Returns the loaded fields, as a plain object of fields or a z.object. |
criteria holds exactly the dependsOn values, converted to their declared types. The choice itself stays
an ordinary field in params.
In the flow editor, the loaded fields appear on the block once the flow builder has filled in every required
field in dependsOn. Change the choice, and they load again: picking TV show replaces Release Year
with First Air Year.
The values the flow builder enters are saved with the block, survive reopening the flow, and reach your
handler next to the declared params: { mediaType: 'movie', releaseYear: 2024 }. On the extension's
Methods tab, the loaded fields appear under Additional parameters.
Two things the loader does not do at run time:
- It does not check the loaded fields. The loader runs only while the block is being configured, so a
loaded field marked required can still arrive empty. Check in
executeanything your handler cannot do without. - It does not apply a loaded field's
.default(). An empty loaded field arrives absent or empty.
To load the fields inside one object param instead, such as the filters of the chosen media type, put
.dynamicProps({ dependsOn, execute }) on a top-level z.looseObject({}) param. The loaded fields render
inside that param on the block, and as a small form on the Methods tab, with an Edit as JSON switch.
The earlier dynamicParams: { criteria, resolve } shape no longer loads. The deploy skips the service and says
what to change:
Skipping "tmdb" — [flow-extension:tmdb] action "discoverTitles": `dynamicParams: { criteria, resolve }` has been replaced by `dynamicParams: { dependsOn, execute }` — name the params it depends on and return the loaded fields from `execute`.
Related¶
- Dictionaries - filling a field from the live API
- Actions - what
executereceives, and what it should return - Service Structure - config fields, which are drawn and checked the same way



![The Test Monitor's Block Results tab for Discover Movies after a run with Minimum Rating set to 11: the Input shows Body Params beginning genreId 878, year null, minRating, and the Output is marked Error with the line [flow-extension:tmdb] invalid params for method "discoverMovies" - minRating: «Minimum Rating» must be at most 10.](../images/extend/discover-run-invalid.png)



