Skip to content

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:

A block's Parameters section showing each type rendered: String as a single-line field holding Dune, Text as a multi-line box holding a note, Number as a numeric field, Boolean as a toggle, Date as a date picker with a calendar icon, Enum as a dropdown, List as a single empty expression box with a list icon and a minus control beside it, and Object as a nested group indenting its own Inner Name field

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:

The Discover Movies Parameters section: Genre set to Science Fiction with a wand, Release Year with its switch off showing an expression input reading (no value) with a wand, Minimum Rating with its switch on showing an empty numeric field, and Sort By with its switch on showing an empty dropdown.

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 handler false, but z.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 as z.string() last.
  • z.record(k, v) - a nested group with no fixed fields, because its keys are open.
  • z.literal(v) - the control for v's type, accepting only v: z.literal(true) is a toggle that has to be switched on, for an "I confirm" field.
  • z.json() - a multi-line box, like z.text(), whose JSON your handler receives already parsed: 42 arrives as a number. Text that does not parse fails with «<label>» must be valid JSON. Do not call JSON.parse on it.
  • z.any() and z.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, even null, 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 a z.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:

The Discover Movies block's Parameters section as first opened: Genre marked red with "Genre is required" beneath its empty picker, Release Year and Minimum Rating as empty numeric fields, Minimum Rating carrying a ? help icon, and Sort By as 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:

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.

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 Discover Movies Parameters section with Genre set to Science Fiction, Release Year and Minimum Rating empty, and the Sort By dropdown open listing Most popular, Newest first and Highest rated.

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.

releasedAfter: z.coerce.date().optional().label('Released After'),

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:

The Parameters section of a block declaring genres and releaseWindow: Genres with two rows, Science Fiction with a × beside it and Adventure, and a + below them; Release Window as a group holding From set to 09/01/2026 and To set to 09/30/2026, each with a calendar icon and an expression switch.

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:

Cast Filters with two cards: the first with Person set to Zendaya and Role set to cast, and a × beside it; the second with Person set to Denis Villeneuve and Role set to crew; a + below the cards.

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 cover tvId or pg.
  • Back id-like fields with a dictionary. Science Fiction in a dropdown beats knowing that the genre id is 878.
  • 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 inside execute is 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 block's Parameters with Media Type set to TV show and the loaded First Air Year field, holding 2021, below it.

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 execute anything 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`.
  • Dictionaries - filling a field from the live API
  • Actions - what execute receives, and what it should return
  • Service Structure - config fields, which are drawn and checked the same way