# 03 — Parameters

All params are **body params**. A handler's `execute` receives the parsed, typed `params`
(`z.infer<typeof params>`) — the schema you declare here is exactly what the handler sees, down to
optionality: `.optional()` gives `T | undefined`, and adding `.default(v)` drops the `undefined` because
the runtime fills it in first. Widgets and behaviors come from the zod type + plugins
(see [02-zod-types.md](02-zod-types.md)); the rest of the bag is typed too — see
[07-actions.md](07-actions.md#inferred-types).

```js
params: z.object({
  chatId: chatIdParam,
  text  : z.text().label('Message'),
  mode  : z.enum(['A', 'B']).optional().label('Mode'),
})
```

## `params` is optional — but if you declare it, it must be a `z.object`

An action or trigger that takes no input either leaves `params` out or declares `params: z.object({})`.
Both are correct; omitting is the shorter one.

```js
svc.addAction({
  category: 'Account', id: 'ping', label: 'Ping', description: 'Health check.',
  result  : { ok: true },
  execute : () => ({ ok: true }),        // no `params` — the handler receives `params: {}`
})
```

Anything that is **not** a `z.object` is rejected. The runtime needs a container of named fields — that is
what it validates input against and what it publishes as the method's input scheme — so there is exactly one
way to declare params, and it is checked the moment the service registers:

```js
params: z.object({ chatId: z.string() })              // ✓ the only form for declared params
params: z.object({})                                  // ✓ or omit the property
params: z.string()                                    // ✗ FR_EXT_INVALID_SCHEMA
params: { chatId: z.string() }                        // ✗ a raw shape is never auto-wrapped
params: {}                                            // ✗
params: z.object({ … }).optional()                    // ✗ a container cannot be optional
params: z.object({ chatId: z.string().optional() })   // ✓ mark the FIELDS optional
```

A raw `{ … }` is never treated as sugar for `z.object({ … })`. Beyond keeping one way to write one thing, a
raw shape is not a zod type — so `params` in your handler would silently degrade from its inferred shape to
`Record<string, unknown>`.

The check runs while the service module loads, so `flowrunner deploy` fails before uploading and names the
item and the slot:

```
[flow-extension:hubspot] action "createContact": `params` must be a z.object({ … }) —
  received z.string(); omit `params` entirely if there is nothing to declare
```

The same rule applies to every schema slot: `configItems`, a dictionary's `criteria`,
`dynamicParams.criteria`, and the object `dynamicParams.resolve()` returns.

## Shared param schemas — one plugin call per line

A param reused across actions is declared once, named with a `Param` suffix, and **its chained plugin calls
each go on their own line, in a column**:

```js
const chatIdParam = z.string()
  .label('Chat')
  .dictionary(getChatsDictionary)
  .describe('Numeric chat ID or @channelusername.')
```

Not `z.string().label('Chat').dictionary(getChatsDictionary).describe('…')`. The chain is a list of
independent facts about the field; one per line means a diff touches only the fact that changed, and a long
chain stays readable instead of running off the edge.

Inline params inside a `z.object({ … })` — the ones used by a single action and not worth naming — stay on
one line while they are short. The rule is about the shared, named schemas.

## Platform param schemas — file options

Some params are not yours to define. Wherever a flow author reaches into **file storage** — choosing which
layer a file lands in, which layer to list, how long a download link lives — the options, their labels, their
wording and their defaults belong to the platform, not to your service. `Flowrunner.z` ships them, so the
definition lives in one place instead of being retyped in every service that touches a file:

```js
params: z.object({
  url        : z.url().label('PDF URL'),
  fileOptions: z.filesUploadOptions(['scope'])
    .label('File Settings')
    .describe('Where to store the downloaded PDF in FlowRunner file storage.')
    .optional(),
})
```

**There is one group per Files call**, and each one's fields are that call's options — so the group you want
is decided by the method your handler ends up calling:

| Factory | Feeds | Fields, in publish order |
|---|---|---|
| `z.filesUploadOptions()` | `files.uploadFile(bytes, options)` | `scope`, `filename`, `ttl`, `objectTtl`, `overwrite`, `generateUrl` |
| `z.filesListOptions()` | `files.list(options)` | `scope`, `limit`, `ttl`, `generateUrl`, `cursor` |
| `z.filesGetOptions()` | `files.get(filename, options)` | `scope`, `ttl`, `generateUrl` |
| `z.filesDeleteOptions()` | `files.delete(filename, options)` | `scope` |

What each field *means* is in [10-files-api.md](10-files-api.md) — it is the same option list, documented
once. Each factory returns a plain `z.object`, so it composes like any other schema: wrap it in your own
`.label()`, `.describe()` and `.optional()`, exactly as above. What comes from the platform is the *inside* —
the dropdown values, the labels, the help text and the defaults. Reword one of those and every service picks
the change up; hand-copy them and yours is the one that goes stale.

### One per call

```js
// upload — where should this file go?
fileOptions: z.filesUploadOptions(['scope']).label('File Settings').optional(),

// list — which layer, and how many?
listOptions: z.filesListOptions(['scope', 'limit']).label('Lookup').optional(),

// get — which layer, and how long should the link live?
getOptions: z.filesGetOptions(['scope', 'ttl']).label('Download').optional(),

// delete — which layer?
deleteOptions: z.filesDeleteOptions().label('Storage').optional(),
```

`z.filesDeleteOptions()` declares one field, so it needs no whitelist; the others are usually worth
subsetting. Each one's parsed value goes straight into the matching call:

```js
execute: async ({ params, files }) => {
  const found = await files.list(params.listOptions)

  return files.get(found.files[0].filename, params.getOptions)
}
```

Each group also identifies itself in the published model — `uiComponent: { type: 'FILE_UPLOAD_OPTIONS' }`,
and `FILE_LIST_OPTIONS` / `FILE_GET_OPTIONS` / `FILE_DELETE_OPTIONS` for the others (see
[11-output-model.md](11-output-model.md)) — which is what lets the platform recognise its own options object
and render it as one. A hand-written copy is just an anonymous object, and gets the generic nested form.

**The argument is the field whitelist.** Expose only what the flow author should decide, and let the handler
choose the rest:

```js
z.filesUploadOptions(['scope'])              // one setting on screen — the common case
z.filesUploadOptions(['scope', 'filename'])  // two
z.filesUploadOptions()                       // all six
```

Two things about that list. The published order is the **group's**, not the list's — `['filename', 'scope']`
still shows `scope` first. And an unknown name throws while the service registers, naming the valid fields,
rather than quietly publishing an empty settings panel.

> **Think before exposing `generateUrl`.** It is the one option whose wrong value fails *quietly*: with it
> off, the call succeeds and the result's `url` is empty, so an action that returns a URL reports success and
> hands the flow nothing. If your action's result carries a URL, leave `generateUrl` out of the whitelist and
> pass `true` yourself — the flow author has no way to know that turning it off breaks the block.

**The group is also the contract at runtime.** A `z.object` strips what it does not declare, so
`z.filesUploadOptions(['scope'])` hands your handler `{ scope }` and nothing else, with the platform default
already filled in. Anything you did not expose is yours to pass:

```js
execute: async ({ params, files }) => {
  const bytes = await Flowrunner.Request.get(params.url).toBuffer()

  return files.uploadFile(bytes, { ...params.fileOptions, filename: 'agreement.pdf' })
}
```

Spread the caller's options **first** — a hardcoded value after the spread is the one you meant to be
non-negotiable, and one before it is silently overridable.

See [10-files-api.md](10-files-api.md) for what each option does.

## Dynamic params — `dynamicParams`

Generate additional top-level param fields at runtime (e.g. fields that depend on a chosen resource):

| Property | Type | Description |
|---|---|---|
| `criteria` | `z.object` | Dependency inputs → become `dependsOn`. |
| `resolve` | fn | `({ criteria, context, apiRequest }) => z.object({...})` — extra params merged into `params`. |

The `resolve` function returns a `z.object(...)` whose fields are merged into the static `params`. The
declared `criteria` inputs become the `dependsOn` set that triggers re-resolution when they change.

`resolve` must return a `z.object` — anything else fails with `FR_EXT_INVALID_SCHEMA` when the console calls
it. This is the one slot that cannot be checked while the service loads, since the schema does not exist
until the resolver runs.
