# 05 — Dictionaries

Dictionaries provide **dynamic options** for a param dropdown. `ext.dictionary(props)` returns a ref used
via `.dictionary(ref)` on a param (see [02-zod-types.md](02-zod-types.md)). A dictionary declares its
`criteria` once, and consumers auto-derive their `dependsOn` from it.

| Property | Type | Description |
|---|---|---|
| `id` | string | Dictionary id — **starts with `get`, ends with `Dictionary`** (`getChatsDictionary`). |
| `category` | string | Optional grouping label. Defaults to `''` — unlike an action's `category`, which is required. The v1 convention was to leave dictionaries uncategorised, so set it only to match a v1 dictionary that declared one. |
| `label` | string | **Human-readable name** → `operationName`. Dictionaries are shown in the UI and exposed to AI tools, so **always set a label**. Falls back to `id` when omitted. |
| `description` | string | Description. |
| `criteria` | `z.object` | Optional dependency inputs (become `dependsOn` on consumers). Omit when the dictionary depends on nothing; declared ⇒ must be a `z.object` (see [03-parameters.md](03-parameters.md)). |
| `result` | `{ items, cursor }` \| `z.` schema | Optional sample output → `metaInfo.sampleResult` + `OBJECT` `returnType`. Omit → `sampleResult: null`, `returnType: OBJECT`. |
| `execute` | fn | see below. |

| `execute` arguments | Returns |
|---|---|
| `{ search, cursor, criteria, context, apiRequest, helpers }` | `{ items: [{ label, value, note? }], cursor? }` (bare array ok if no pagination) |

```js
const getTablesDictionary = ext.dictionary({
  id         : 'getTablesDictionary',
  label      : 'Tables',
  description: 'Tables in the base',
  criteria   : z.object({ baseId: z.string().label('Base ID') }),
  result     : { items: [{ label: 'Base A', value: 'a' }], cursor: null },
  execute    : async ({ search, criteria, apiRequest }) => {
    return { items: [], cursor: null }
  },
})

// consumer:
const tableIdParam = z.string()
  .label('Table')
  .dictionary(getTablesDictionary)      // auto dependsOn: ['baseId']
```

- The `execute` bag is the shared handler context (see [07-actions.md](07-actions.md)) plus `search`,
  `cursor`, and the resolved `criteria`.
- Return `{ items, cursor }` for paginated sources, or a bare `items` array when there is no pagination.
- **The `id` starts with `get` and ends with `Dictionary`**, and the const holding the ref carries the same
  name — so the declaration and every `.dictionary(ref)` read alike.

## Browse versus resolve — a dictionary does not need a listing endpoint

`execute` receives **`search`**, the text the flow author has typed. That makes two different dictionaries
possible, and only the first needs an endpoint that enumerates anything:

- **Browse** — the API has a listing endpoint. Ignore or filter by `search`, page with `cursor`, return the
  set. This is the common case.
- **Resolve** — the API has no listing endpoint but *can* look up one entity by id, handle or URL. Call that
  lookup with `search` and return the single hit, labelled with something human (`'Team Standup (group)'`)
  and valued with the id. Return an empty array for empty or unmatched `search`, and use `description` to
  tell the author what to type.

**"The API has no endpoint that lists these" is not a reason to leave a parameter as free text.** It rules
out *browse*, not *resolve*. A resolve dictionary still validates the value before the flow runs, still shows
the author the name of the thing they picked instead of a bare id, and still surfaces the parameter to AI
tools as a resolvable option. Reach for plain free text only when the API can neither list nor look up the
value — and say so in the service `README.md` when that happens.

```js
// Resolve: no "list my chats" endpoint exists, but a single chat can be fetched by id or @username
const getChatsDictionary = ext.dictionary({
  id         : 'getChatsDictionary',
  label      : 'Chats',
  description: 'Type a numeric chat ID or @username to resolve the chat.',
  result     : { items: [{ label: 'Team Standup (group)', value: '-1001234567890' }], cursor: null },
  execute    : async ({ search, apiRequest }) => {
    if (!search?.trim()) {
      return { items: [], cursor: null }
    }

    try {
      const chat = await apiRequest({ url: '/getChat', query: { chat_id: search.trim() } })

      return { items: [{ label: `${ chat.title ?? chat.username } (${ chat.type })`, value: String(chat.id) }] }
    } catch (error) {
      if (isUnresolvableInput(error)) {
        return { items: [], cursor: null }        // unresolvable input is an empty list, not an error
      }

      throw error                                 // a credential or transport failure is not "no matches"
    }
  },
})
```

A resolve dictionary usually declares no `criteria` — the input is `search`, not a dependency on another
field.

**Validation note.** The payload is validated in two steps: the `{ search, cursor, criteria }` envelope,
then `criteria` on its own against the declared schema. A criteria failure is reported separately
(`FR_EXT_INVALID_CRITERIA`) so the UI can point at the dependent fields — which is why `criteria` should
list exactly the inputs `execute` needs, and mark anything genuinely optional as such. `execute` receives
the parsed criteria (defaults applied, unknown fields dropped).

**Model note.** A dictionary is invoked through the helper-method route with a single `payload`
(`{ search, cursor, criteria }`) — the runner synthesizes a `payload` OBJECT bodyParam describing
`search` + `cursor` (+ a nested `criteria` object when `criteria` is declared) and puts the `"Payload"`
label in `metaInfo.args`. You do **not** declare `search`/`cursor` yourself. See
[11-output-model.md](11-output-model.md).
