# 07 — Actions & the Context Bag

Actions are the primary operations a service exposes. Register one with `ext.addAction(props)`.

| Property | Type | Req | Description |
|---|---|---|---|
| `category` | string | ✓ | Grouping label. |
| `id` | string | ✓ | Stable action id — persisted, never changes. |
| `label` | string | ✓ | Display name. |
| `description` | string | ✓ | Description. |
| `params` | `z.object` | – | Input params — omit if it takes none (see [03-parameters.md](03-parameters.md)). |
| `dynamicParams` | object | – | Runtime-generated params (see [03-parameters.md](03-parameters.md)). |
| `result` | sample object | ✓ | A literal sample of what `execute` returns — see below. |
| `logo` / `appearanceColors` | – | – | Default to the extension's. |
| `oauth2Scopes` | string[] | – | Extra OAuth scopes (unioned). |
| `execute` | fn | ✓ | Handler; receives the ctx bag. |

## Input validation

The runtime parses the incoming input against `params` **before** `execute` runs, so a handler can trust
what it receives: defaults are applied, values are coerced by the schema, and unknown fields are stripped
(an action with `dynamicParams` keeps them — its generated fields aren't in the static schema). A violation
rejects the call with `FR_EXT_INVALID_METHOD_INPUT` and the offending param paths; don't re-check required
params by hand inside `execute`. Declare optionality honestly — a `z.string()` that the flow may legitimately
leave empty must be `.optional()`.

**Not every slot is equally strict**, because not every slot has the same producer. Worth knowing so you
declare schemas that hold up in each:

| Slot | Strictness |
|---|---|
| Action `params`, SYSTEM invocations | **strict** — required fields enforced, unknown stripped. 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 its `dependsOn` fields are filled |
| Sample-result / dynamic-params loader `criteria` | **lenient** — the schema is relaxed so nothing is required, and unknown fields ride through. These fire on every keystroke while the form is half-filled |
| Polling trigger `params` | **lenient** — the runtime polls with whatever the instance was saved with; rejecting a poll would silently stop a live automation |

So a loader's `result` fn must tolerate partial criteria, and a trigger's `fetch`/`poll` must tolerate a
partially configured instance. Handlers reached through a strict slot need no such defence.

## `result` — return the sample object

**`result` is a literal sample of what `execute` returns. Write the object, nothing else.**

```js
result: {
  message_id: 42,
  date      : 1714000000,
  chat      : { id: -1001234567890, type: 'supergroup', title: 'Team Standup' },
  text      : 'Hello',
},
```

Do **not** declare a zod schema and then attach a sample to it. A schema here is a second description of the
same thing: it has to be kept in step with the handler by hand, it is not validated against what `execute`
actually returns, and the platform publishes only the sample from it anyway
([11-output-model.md](11-output-model.md)). The object is the shorter, truer statement.

Two cases still justify something else:

- **A schema you genuinely reuse** — the same shape returned by several actions, declared once and
  referenced. Use `z.object(...).example({...})` then, and only then; the value is the reuse, not the typing.
- **A loader** — `({ params, context, apiRequest }) => sample` — when the shape is not known until the flow
  author has configured the action, because it comes from the user's own data.

Take the sample from a real response. An invented one drifts from the API and misleads every flow author who
builds against it.

## `execute`

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

```js
execute: async ({ params, apiRequest }) => {
  return apiRequest({ url: `/messages/${ params.messageId }`, method: 'delete' })
},
```

### Return the provider's response, do not narrow it

**An action returns what the API returned.** Extend it or rename fields when the vendor's shape is genuinely
unusable, but do not reduce a response to the part you happen to care about — every field you drop is one a
flow author cannot reach, and no amount of extra actions gives it back.

```js
// ✗ throws the entire response away and answers with a boolean the API never sent
execute: async ({ params, apiRequest }) => {
  return {
    success: Boolean(await apiRequest({ url: '/deleteMessage', method: 'post', body: { … } })),
  }
},

// ✓ hands back what the API said
execute: async ({ params, apiRequest }) => {
  return apiRequest({ url: '/deleteMessage', method: 'post', body: { … } })
},
```

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 — but the original fields stay.

## `execute` context bag

The same bag is passed to actions, dictionaries, triggers, and result-loaders (accessed via arguments,
never `this`). Every key is typed — nothing in the bag is `unknown`, and the **Inferred as** column says
where each type comes from:

| Key | Inferred as | Description |
|---|---|---|
| `params` | `z.infer` of **this handler's own** `params` | Parsed input — defaults applied, values coerced, unknown fields stripped. `{}` when the method declares none. |
| `context` | return type of `initContext` | Whatever the factory built (see [01-service-structure.md](01-service-structure.md)). |
| `config` | `z.infer` of `configItems` | Resolved config values. |
| `oauth` | `{ accessToken?: string, token?: unknown }` | From the `oauth-access-token` header only; see [06-oauth2.md](06-oauth2.md). |
| `apiRequest` | your own `apiRequest`, minus `context` / `logger` | Shared HTTP helper — the runtime injects those two, so you never pass them (see [04-http-requests.md](04-http-requests.md)). |
| `files` | the file-service client | Present only when `usesFileStorage:true` (else a proxy that throws; see [10-files-api.md](10-files-api.md)). |
| `filesScope` | `{ WORKSPACE, FLOW, EXECUTION }` | The storage layers, so you name one instead of spelling the string. Always present, opt-in or not. |
| `logger` | `{ debug, info, warn, error }` | Runtime logger (no bare `console.log`). |
| `helpers` | return type of `helpers(ctx)` | Bound helpers (present when `createExtension({ helpers })`). |

## Inferred types

You declare the schemas; the types follow. `createExtension` resolves `config` / `context` / `helpers` /
`apiRequest` **once** for the whole service, and each registrar infers its own `params` from the schema
sitting next to the handler — so two actions see two different `params` types, over the same `context`:

```js
const ext = Flowrunner.createExtension({
  configItems: z.object({ apiKey: z.string(), retries: z.number().optional() }),
  initContext: ({ config }) => ({ base: `key:${config.apiKey}` }),
  apiRequest : async ({ url, context }) => Flowrunner.Request.get(`${context.base}${url}`),
  helpers    : ({ apiRequest }) => ({ listAll: (url) => listAll(apiRequest, url) }),
})

ext.addAction({
  // …
  params : z.object({
    name  : z.string(),
    count : z.number().optional().default(1),
    mode  : z.enum(['fast', 'slow']).optional(),
  }),
  execute: ({ params, context, config, helpers }) => {
    params.name       // string
    params.count      // number             — a .default() means it is always there
    params.mode       // 'fast' | 'slow' | undefined
    context.base      // string             — from initContext
    config.apiKey     // string
    config.retries    // number | undefined — .optional() with no default
    params.nope       // ✗ not a declared param

    return helpers.listAll('/things')
  },
})
```

Optionality carries through exactly as declared: `.optional()` yields `T | undefined`, and adding
`.default(v)` removes the `undefined` because the runtime fills it in before `execute` runs.

The same inference reaches every other handler that owns a schema — a dictionary's `criteria`, a polling
trigger's `params` in `fetch` / `poll` / `watermark.by`, a realtime trigger's `params` in `providerEventName`
and `match`, and a loader `result` fn, which is typed by its owner's params.

**Payloads shaped by a third party stay open on purpose** — a webhook `requestBody`, a `providerEventData`,
a stored `webhookData`. The runtime cannot know their shape, and requiring a cast before every field read
would be noise, so they are left free to read into. Validate what matters before you trust it.

`oauth.token` is also open: `setupOauth2` runs after the service shape is resolved, so a
`deserializeToken` return can't flow back into the bag — annotate it at the point of use if it matters.

> **Editor support.** Services are plain `.js`, so these types show up as hover text and autocompletion via
> the global `Flowrunner` declaration; they are not reported as inline errors unless the project opts into
> checking JS. A service authored in TypeScript gets the same types as real compile errors.
