Skip to content

Actions

An action runs your execute handler when the flow reaches it, and hands what the handler returns to the rest of the flow. Register one with ext.addAction() and it appears in the palette under the category you name.

ext.addAction({
  category   : 'TMDB',
  id         : 'getMovieDetails',
  label      : 'Get Movie Details',
  description: 'Returns everything TMDB holds about one movie.',
  params     : z.object({
    movieId: z.string().label('Movie ID').describe('TMDB numeric id, e.g. 693134'),
  }),
  result : { id: 693134, title: 'Dune: Part Two', runtime: 167, vote_average: 8.133 },
  execute: async ({ params, apiRequest }) => {
    return apiRequest({ url: `/movie/${ params.movieId }` })
  },
})

What you declare

Property Type Required What it does
category string yes The palette group the block sits in.
id string yes Persisted into every flow that places this block. Frozen once deployed.
label string yes The name on the block.
description string yes Shown under the label in the palette and the console.
params z.object no The fields on the block. Leave it out for an action with no input. See Parameters & Types.
result sample yes A sample of what execute returns.
execute function yes Called when the flow reaches the block. What it returns becomes the block's result.
dynamicParams object no Fields generated at runtime.
logo / appearanceColors string / [string, string] no Override the extension's, for this block only.
oauth2Scopes string[] no Extra scopes, unioned with those in ext.setupOauth2(). See OAuth2.

Every action you register shows up on the extension's Methods tab with its kind and category:

The Methods tab listing each declared item with its id, label, kind badge and description: three ACTION entries, three DICTIONARY entries, one POLLING_TRIGGER and one SYSTEM method

What the handler receives

execute is called with one object. Take what you need from it by name:

Key What it is
params The parsed input for this action, typed from its own params schema.
context Whatever initContext returned.
config The values the workspace filled in.
apiRequest Your shared HTTP helper, with context and logger already supplied.
helpers The bound helpers your extension declared.
logger debug, info, warn, error. Use it instead of console.log.
oauth { accessToken, token } when the extension declares setupOauth2. See OAuth2.
files The file client, when the extension declares usesFileStorage. See Storing Files.
filesScope The three storage layers as named constants: filesScope.WORKSPACE, .FLOW, .EXECUTION.

Input is already validated

The runtime checks the incoming values against params before execute runs. Values are converted to the declared type, unknown fields are dropped, a blank field is resolved by its modifier, a .default() is filled in, and a violation fails the block with a message that names the field by its label:

[flow-extension:tmdb] invalid params for method "discoverMovies" — minRating: «Minimum Rating» must be at most 10

That is what the Test Monitor shows for a bad value, and every offending field is listed at once. So do not re-check required params or ranges by hand. Declare optionality and limits honestly instead, and let the schema carry them - see Parameters & Types.

Not every entry point is equally strict, which matters when you write handlers other than an action's:

Handler Strictness
Action params Strict. The flow engine produces these against the published model, so a violation is a real contract break.
Dictionary criteria Strict. The console only calls a dictionary once the fields it depends on are filled.
Sample-result loader Waits for the required params. The console calls it while a block is being edited, but only once every required field has a value, so a loader can assume its input is complete.
Dynamic-param loader Lenient. It fires while the form is half-filled, so tolerate partial input.
Polling trigger params Lenient. The runtime polls with whatever the instance was saved with; rejecting a poll would stop a live automation without saying so.

Declaring the result

result is a literal sample of what execute returns. Write the object and nothing else:

result: {
  id          : 693134,
  title       : 'Dune: Part Two',
  release_date: '2024-02-27',
  runtime     : 167,
  vote_average: 8.133,
  genres      : [{ id: 878, name: 'Science Fiction' }, { id: 12, name: 'Adventure' }],
},

It is what a flow builder binds against when wiring your block's output into a later step, so take it from a real response. An invented sample drifts from the API and misleads everyone who builds against it.

Do not declare a zod schema and attach a sample to it. A schema here describes the same thing twice, is never checked against what execute actually returns, and only the sample is published anyway. Two cases justify something else: a shape genuinely reused across several actions, declared once with z.object(...).example({...}); and a shape not known until the flow builder has configured the block, which takes a loader function ({ params, context, apiRequest }) => sample.

Return what the API returned

Extend the provider's response where its shape is unusable, but do not reduce it to the part you happen to care about - every field you drop is one a flow builder cannot reach, and no number of extra actions gives it back.

// Throws away the response and answers with a boolean the API never sent
execute: async ({ params, apiRequest }) => {
  return { success: Boolean(await apiRequest({ url: `/movie/${ params.movieId }` })) }
},

// Hands back what the API said
execute: async ({ params, apiRequest }) => {
  return apiRequest({ url: `/movie/${ params.movieId }` })
},

The same applies to a list endpoint: return the items and the paging fields around them, not a bare array. Adding a derived field is fine - spread the response and add to it.

Write execute with a block body and an explicit return. A concise arrow returning an object literal reads as a shape declaration rather than a call, and it is where responses quietly get reshaped.

Failing a block

Throw. The block's error exit carries the message, and the flow can handle it like any other block's failure.

Normalize failures once, in apiRequest, rather than in every action - a non-2xx rejects with a ResponseError whose message is the raw response body, so an unhandled one surfaces as [object Object]. Keep status on the error you rethrow, because a dictionary needs it to tell an unresolvable value from a rejected credential.

The Test Monitor shows the message, not a code, so make the message itself say what went wrong. A plain Error you throw arrives as its message under a generic execution error; the runtime's own failures carry a stable FR_EXT_* code as well - see Troubleshooting.

Trying an action without a flow

The Execute tab on the extension's page - Custom Extensions in the workspace navigation, then the extension - runs a single method with values you type, which is the fastest way to confirm an action works before building anything around it:

The Execute tab with Get Movie Details selected, movieId filled in, and the returned JSON under the Result heading

Inside a flow, the Test Panel at the top of a selected block's configuration does the same in place, and the Test Monitor below the canvas shows the input alongside the result:

The Test Monitor's Block Results tab showing the input parameters on the left and the returned JSON marked Success on the right

Designing an action

  • One action, one operation. A block that switches on a mode parameter has a result that describes none of its outputs.
  • Name the label for the canvas, not for the API - Get Movie Details, not movieEndpoint. Ids are frozen after the first deploy; labels can change whenever you like.
  • Group with category so related blocks arrive together in the palette.