# 11 — Service Model

Declaring a service produces the platform's service model — `SharedExtensionServiceInfo` + `methodsMap` +
config descriptions. This is what the console UI, the flow editor and AI tools consume, so it is the contract
your declarations have to satisfy.

| What you declare | Model output |
|---|---|
| `createExtension({ id, name, … })` | `SharedExtensionServiceInfo` |
| `configItems` (`.shared()` → shared) | `appConfigDescriptions` / `sharedConfigDescriptions`, **plus `configSchema`** |
| `addAction` | `SharedExtensionMethod` `registerAs: ACTION` |
| `dictionary` | `SharedExtensionMethod` `registerAs: DICTIONARY` — `label`→`operationName` (else `id`); `result`→`sampleResult` (+`OBJECT` return) |
| `addPollingTrigger` / `addRealtimeTrigger` | `SharedExtensionMethod` `registerAs: POLLING_TRIGGER` / `REALTIME_TRIGGER` |
| `params` | `bodyParams` + `metaInfo.args` (widget derived from zod type — see below), **plus `paramsSchema`** |
| `result` (value/schema/loader) | `metaInfo.sampleResult` / `sampleResultLoader` |
| `scope` (realtime) | `integrationTriggersScope` |
| `handleTrigger*` | SYSTEM methods (runtime-generated) |

Platform-side identity fields are added downstream — you never author them.

The names change on the way out, which matters when you inspect a built model:

| Authored | Published as |
|---|---|
| `id` | `serviceInfo.serviceClassName` |
| `name` | `serviceInfo.integrationName` |
| `logo` | `serviceInfo.integrationIcon` |
| `scope` (realtime) | `serviceInfo.integrationTriggersScope` |
| `appearanceColors` | per-method `colorStart` / `colorEnd` (every method inherits them) |
| `label` | per-method `operationName` |

`serviceInfo` also carries `requireOAuth`, and the definition root carries `runnerVersion` (`'v2'`). A
service's `description` and `appearanceColors` are not published on `serviceInfo` itself.

## The schema travels too

Alongside the projections above, the model carries the schema you actually declared:

| Field | Where | Holds |
|---|---|---|
| `configSchema` | definition root | your `configItems` object |
| `paramsSchema` | each `ACTION` / `POLLING_TRIGGER` / `REALTIME_TRIGGER` | that method's `params` |

The console rebuilds real zod from it and validates against that, which is why a config dialog can report
`«Retries» must be at least 1` rather than only "this field is required".

Two things follow for you as an author:

- **Nothing is lost by declaring more.** `.min()`, `.regex()`, a format like `z.email()`, your own message —
  all of it reaches the form. Only function-backed rules (`.refine()`, `.transform()`, `.trim()`) cannot
  travel; see [02-zod-types.md](02-zod-types.md).
- **A missing `.label()` or `.describe()` is filled in when the field is DRAWN, not when your service is
  built.** `z.email()` with neither shows "Contact Email" (from the field key) and "Expects an email address,
  e.g. foo@bar.com". Because that happens at draw time, the wording can improve without you redeploying —
  and anything you write yourself always wins.

`bodyParams`, `metaInfo.args` and the config descriptions are unchanged and still authoritative for
everything that reads them; the schema is additional, not a replacement.

## Param split: `bodyParams` vs `metaInfo.args`

All params are body params (`queryParams` is always `[]`). A param's information is split:

| `bodyParams` (structural) | `metaInfo.args` (display) |
|---|---|
| `name`, `required`, `description`, `type`, `items`, `properties`, `defaultValue` | `label`, `dictionary`, `dependsOn`, `uiComponent` |

**Nested object properties are the exception** — they inline their display metadata (`label`, `uiComponent`,
`dictionary`, `dependsOn`) directly on the property.

**A platform param group is the other exception.** `z.filesUploadOptions([…])` and its siblings
(see [03-parameters.md](03-parameters.md)) publish their `uiComponent` token on the param *as well as* in
`metaInfo.args`, at every depth. That token says what the param **is**, not how one control should look, so a
consumer walking `bodyParams` alone can still tell the platform's own options object from an anonymous
`z.object` a service declared.

### Type → widget derivation

There is no `.uiComponent()`; the widget follows the zod type (after unwrapping
`.optional()` / `.nullable()` / `.default()`):

| zod | `type` | `uiComponent` |
|---|---|---|
| `z.string()` | `STRING` | — |
| `z.text()` | `STRING` | `MULTI_LINE_TEXT` |
| `z.number()` | `NUMBER` | `NUMERIC_STEPPER` |
| `z.boolean()` | `BOOLEAN` | `TOGGLE` |
| `z.date()` | `STRING` | `DATE_PICKER` |
| `z.enum([...])` | `STRING` | `DROPDOWN` — `{ value, label }` pairs when `.labels()` is declared, else the enum values |
| `z.object({...})` | `OBJECT` | — (recurses into `properties`) |
| `z.array(inner)` | `LIST` | — (`items.type` from the element) |
| `z.filesUploadOptions([…])` | `OBJECT` | `FILE_UPLOAD_OPTIONS` — and `FILE_LIST_OPTIONS` / `FILE_GET_OPTIONS` / `FILE_DELETE_OPTIONS` for the siblings |

### `result` → `sampleResult`

| `result` form | `sampleResult` | `returnType` | `sampleResultLoader` |
|---|---|---|---|
| literal value | the value | array → `LIST`, object → `OBJECT`, string → `STRING`, else `VOID` | `null` |
| zod schema | the last `.example()` on it, else `null` | `OBJECT` | `null` |
| loader fn | `null` | `OBJECT` | `{ methodName: '<id>_SampleResultLoader', dependsOn: [all params] }` |

### `configItems` → config descriptions

| zod | config item `type` |
|---|---|
| `z.boolean()` | `bool` |
| `z.date()` | `date` |
| `z.enum([...])` | `choice` (+ `options`) |
| `z.text()` | `text` |
| anything else | `string` |

`.shared()` → `shared: true`, routing the field to the global config **dialog** instead of the per-app one.
It does not change how a handler reads the value, and a custom install has no global tier to route to.

## Helper-route & SYSTEM methods carry a wrapper-param `metaInfo.args`

Dictionaries, sample-result / dynamic-params loaders, and the trigger/polling/OAuth SYSTEM methods take a
**single wrapper param** (never per-field params). The model describes that wrapper so `metaInfo.args` is
never empty — consumers (UI, AI tools) need the label:

| Method kind | `bodyParams` | `metaInfo.args` |
|---|---|---|
| Trigger / polling / OAuth SYSTEM | `[{ invocation }]` / `[{ callbackObject }]` / `[{ refreshToken }]` | labelled from that param (e.g. `{ invocation: { label: 'invocation' } }`) |
| Loaders (`*_SampleResultLoader` / `*_DynamicParams`) | `[{ payload: OBJECT }]` | `{ payload: { label: 'payload' } }` |
| Dictionary | synthesized `payload` OBJECT with nested `search` + `cursor` (+ `criteria` when declared) | `{ payload: { label: 'Payload' } }` |

You never author these params — the runtime emits them. Dictionaries keep the extension's **service colors**
and default to `OBJECT` `returnType`.

## Runtime surfaces worth knowing

- **Crypto** lives on the namespace as `Flowrunner.crypto` — `hmac(algo, key, data, encoding)`,
  `timingSafeEqual(a, b)`, `ageSeconds(timestamp)`. Not on ctx `helpers`. See
  [09-realtime-triggers.md](09-realtime-triggers.md).
- **The custom type** is `z.text()` → `uiComponent: MULTI_LINE_TEXT`, `STRING`-typed — see the type table
  above.

## Open items

- The client-side render contract for **top-level** `dynamicParams` fields. The runtime emits and serves the
  `PARAM_SCHEMA_DEFINITION` method today, so the authoring side works; `metaInfo.dynamicParams` is marked
  provisional in the model.
