# 14 — Reference Service

One complete service that exercises the whole surface once: config, `initContext`, `apiRequest`, `helpers`,
a dictionary, two actions, a polling trigger and two realtime triggers. **Copy the shape, not the domain** —
including the layout, the naming and the formatting, which are rules rather than taste
([01-service-structure.md](01-service-structure.md), [03-parameters.md](03-parameters.md)).

> **Read this instead of the CLI templates.** The templates that ship with the CLI are project scaffolding,
> not authoring reference. Do not open `node_modules/flowrunner-cli/templates/`, or anything else inside the
> package.

This file is verified against the runtime: it builds, and the model it produces is the one annotated at the
bottom. Numbers in comments point at the document that governs the line.

This service assumes the standard layout — note that the icon lives in `public/`, not beside `src/`
(see [01-service-structure.md](01-service-structure.md#folder-layout)):

```
services/notes/
├── src/index.js
├── public/icon.svg
└── README.md
```

```js
// Reference service — exercises the whole authoring surface once.
const z = Flowrunner.z

const ext = Flowrunner.createExtension({
  id              : 'notes',
  name            : 'Notes',
  description     : 'Reference service — notes, boards and their events.',
  logo            : '/icon.svg',                 // → services/notes/public/icon.svg — the file must exist there
  appearanceColors: ['#2D7FF9', '#B9D5FF'],
  usesFileStorage : false,

  // 02: widget follows the zod type. .shared() routes to the global tier; a z.number() config field
  // RENDERS as a text box (11) but still reaches the handler as a number — no conversion needed.
  configItems: z.object({
    apiToken     : z.string().label('API Token').describe('Personal token from Settings → API'),
    webhookSecret: z.string().optional().label('Webhook Secret'),
    pageSize     : z.number().optional().default(50).label('Page Size'),
  }),

  // 01: runs once per invocation; its return type IS `context` in every handler. Widen it here.
  initContext: ({ config }) => ({
    baseUrl : 'https://api.notes.example/v1',
    token   : config.apiToken,
    pageSize: config.pageSize,
  }),

  // 04: the one place envelope-unwrap, error-normalize and redaction live.
  apiRequest: async ({ url, method = 'get', body, query, context, logger, logTag }) => {
    const fullUrl = `${ context.baseUrl }${ url }`

    logger?.debug(`[${ logTag ?? 'api' }] ${ method.toUpperCase() } ${ fullUrl }`)

    try {
      // 04: the verb is a value here, so the constructor form — never Request[method](url).
      const response = await new Flowrunner.Request(fullUrl, method, body)
        .set({ Authorization: `Bearer ${ context.token }` })
        .query(query ?? {})

      return response?.data ?? response
    } catch (error) {
      // 04: a non-2xx rejects with ResponseError — `message` is the raw body, so normalize it.
      throw new Error(`Notes API ${ error?.status ?? '?' }: ${ describeFailure(error) }`)
    }
  },

  // 01: helpers carry real reusable logic. Paging a list endpoint to the end is logic; renaming
  // apiRequest, or wrapping `files`, is not — those are already in every handler bag.
  helpers: ({ apiRequest, context }) => ({
    listAllNotes: async (boardId) => {
      const all = []
      let cursor = null

      do {
        const page = await apiRequest({
          url  : `/boards/${ boardId }/notes`,
          query: { cursor, limit: context.pageSize },
        })

        all.push(...(page.notes ?? []))
        cursor = page.next ?? null
      } while (cursor)

      return all
    },
  }),
})

// 05: id starts with `get`, ends with `Dictionary`; the const carries the same name.
const getBoardsDictionary = ext.dictionary({
  id         : 'getBoardsDictionary',
  label      : 'Boards',
  description: 'Boards in the workspace',
  criteria   : z.object({ workspaceId: z.string().label('Workspace ID') }),
  result     : { items: [{ label: 'Roadmap', value: 'brd_1' }], cursor: null },
  execute    : async ({ search, cursor, criteria, apiRequest }) => {
    const response = await apiRequest({
      url  : `/workspaces/${ criteria.workspaceId }/boards`,
      query: { q: search, cursor },
    })

    return {
      items : (response.boards ?? []).map(board => ({ label: board.name, value: board.id })),
      cursor: response.next ?? null,
    }
  },
})

// 03: a shared param is named `…Param` and gets one plugin call per line.
const workspaceIdParam = z.string()
  .label('Workspace ID')
  .describe('Found in the workspace URL')

const boardIdParam = z.string()
  .label('Board')
  .dictionary(getBoardsDictionary)           // auto dependsOn: ['workspaceId']
  .describe('Board the note belongs to')

ext.addAction({
  category   : 'Notes',
  id         : 'createNote',
  label      : 'Create Note',
  description: 'Creates a note on a board.',
  params     : z.object({
    workspaceId: workspaceIdParam,
    boardId    : boardIdParam,
    body       : z.text().label('Body'),
    visibility : z.enum(['private', 'workspace']).default('private').label('Visibility')
      .labels({ private: 'Private', workspace: 'Workspace' }), // UI shows the label, the flow stores the value
    pinned     : z.boolean().optional().default(false).label('Pinned'),
  }),
  // 07: the sample object IS the result declaration. No zod schema, no .example() beside it.
  result: {
    id        : 'note_1',
    body      : 'Ship it',
    visibility: 'private',
    pinned    : false,
    boardId   : 'brd_1',
    createdAt : '2026-01-01T00:00:00Z',
  },
  // 07: block body with an explicit return, and the API's own response handed back whole.
  execute: async ({ params, apiRequest }) => {
    return apiRequest({
      url   : `/boards/${ params.boardId }/notes`,
      method: 'post',
      body  : { body: params.body, visibility: params.visibility, pinned: params.pinned },
      logTag: 'createNote',
    })
  },
})

ext.addAction({
  category   : 'Notes',
  id         : 'listBoardNotes',
  label      : 'List Board Notes',
  description: 'Lists every note on a board, following pagination to the end.',
  params     : z.object({
    workspaceId: workspaceIdParam,
    boardId    : boardIdParam,
  }),
  result: {
    boardId: 'brd_1',
    total  : 2,
    notes  : [{ id: 'note_1', body: 'Ship it', createdAt: '2026-01-01T00:00:00Z' }],
  },
  // 07: extending the provider's data is fine — dropping any of it is not.
  execute: async ({ params, helpers }) => {
    const notes = await helpers.listAllNotes(params.boardId)

    return { boardId: params.boardId, total: notes.length, notes }
  },
})

// 08: exactly one mechanism. `params` here are lenient — tolerate a half-configured instance.
ext.addPollingTrigger({
  category   : 'Notes',
  id         : 'onNoteCreated',
  label      : 'On Note Created',
  description: 'Fires for each new note on a board.',
  params     : z.object({ boardId: z.string().optional().label('Board').dictionary(getBoardsDictionary) }),
  result     : { id: 'note_1', body: 'Ship it', boardId: 'brd_1', createdAt: '2026-01-01T00:00:00Z' },
  fetch      : async ({ params, apiRequest }) => {
    if (!params.boardId) {
      return []
    }

    const response = await apiRequest({ url: `/boards/${ params.boardId }/notes`, query: { sort: '-createdAt' } })

    return response.notes ?? []
  },
  watermark: { by: 'createdAt', sortedDesc: true },
})

// 09: one webhook registration, declared once, serving every realtime trigger below.
ext.setupRealtimeTriggers({
  scope                   : 'SINGLE_APP',
  extractProviderEventName: 'event',
  extractProviderEventData: 'data',
  verify                  : {
    header  : 'X-Notes-Signature',
    algo    : 'sha256',
    encoding: 'hex',
    secret  : (config) => config.webhookSecret,
  },
  subscribe: async ({ callbackUrl, triggers, webhookData, apiRequest }) => {
    if (webhookData?.id) {
      await apiRequest({ url: `/hooks/${ webhookData.id }`, method: 'delete' })
    }

    const events = [...new Set(triggers.map(trigger => trigger.providerEventName))]
    const hook   = await apiRequest({ url: '/hooks', method: 'post', body: { url: callbackUrl, events } })

    return { webhookData: { id: hook.id } }
  },
  unsubscribe: async ({ webhookData, apiRequest }) => {
    if (webhookData?.id) {
      await apiRequest({ url: `/hooks/${ webhookData.id }`, method: 'delete' })
    }

    return { webhookData: {} }
  },
})

// 09: ONE TRIGGER PER EVENT. Both share the single webhook above; neither takes an event-type param.
ext.addRealtimeTrigger({
  category         : 'Notes',
  id               : 'onNoteUpdated',
  label            : 'On Note Updated',
  description      : 'Fires when a note changes.',
  providerEventName: 'note.updated',
  params           : z.object({
    boardId   : z.string().optional().label('Board').dictionary(getBoardsDictionary),
    visibility: z.enum(['private', 'workspace']).optional().label('Visibility')
      .labels({ private: 'Private', workspace: 'Workspace' }),
  }),
  result: { id: 'note_1', body: 'Ship it', boardId: 'brd_1', visibility: 'private' },
  // 13: `match` gets the SAVED params — no .default() filled. Normalize by hand, and treat an
  // absent value as "no filter".
  match: ({ params, providerEventData }) => {
    if (params.boardId && params.boardId !== providerEventData.boardId) {
      return false
    }

    if (params.visibility) {
      const wanted = toApiVisibility(params.visibility)

      if (wanted !== providerEventData.visibility) {
        return false
      }
    }

    return true
  },
  extendResult: async ({ providerEventData, apiRequest }) => {
    return {
      ...providerEventData,
      board: await apiRequest({ url: `/boards/${ providerEventData.boardId }` }),
    }
  },
})

ext.addRealtimeTrigger({
  category         : 'Notes',
  id               : 'onNoteDeleted',
  label            : 'On Note Deleted',
  description      : 'Fires when a note is deleted.',
  providerEventName: 'note.deleted',
  params           : z.object({
    boardId: z.string().optional().label('Board').dictionary(getBoardsDictionary),
  }),
  result: { id: 'note_1', boardId: 'brd_1', deletedAt: '2026-01-02T00:00:00Z' },
  // 09: the declarative form — type-coerced equality per filled param, empty param matches all.
  match : { boardId: 'boardId' },
})

// 01: module-level helpers are function declarations, and they live at the end of the file.

const VISIBILITY_BY_LABEL = {
  Private  : 'private',
  Workspace: 'workspace',
}

function toApiVisibility(label) {
  // 16: a plain object, guarded at the point of the lookup.
  return Object.hasOwn(VISIBILITY_BY_LABEL, label) ? VISIBILITY_BY_LABEL[label] : label
}

function describeFailure(error) {
  const detail = error?.body?.error?.message ?? error?.message

  return typeof detail === 'string' ? detail : JSON.stringify(detail)
}
```

## The model it produces

Verified with `buildServiceDefinition` (see [13-runtime-contracts.md](13-runtime-contracts.md)):

```
methods     : createNote, listBoardNotes, getBoardsDictionary, onNoteCreated, onNoteUpdated,
              onNoteDeleted, handleTriggerPollingForEvent, handleTriggerUpsertWebhook,
              handleTriggerResolveEvents, handleTriggerSelectMatched, handleTriggerDeleteWebhook
configItems : apiToken:string, webhookSecret:string, pageSize:string
```

Six authored items, five runtime-owned SYSTEM methods. `handleTriggerRefreshWebhook` is absent because no
`refresh` was declared — the SYSTEM set follows what you declare
([09-realtime-triggers.md](09-realtime-triggers.md)).

Things in that output worth reading twice:

- **`pageSize:string`** — declared `z.number()`, published as `string`. Config fields collapse to five types
  ([02-zod-types.md](02-zod-types.md)), which is why `initContext` wraps it in `Number(...)` rather than
  trusting the declared type.
- **`boardId`** gets `dependsOn: ['workspaceId']` — visible at `metaInfo.args.boardId.dependsOn` — with
  nothing declared at the param. It is derived from the dictionary's `criteria`
  ([05-dictionaries.md](05-dictionaries.md)).
- **`visibility`** shows `Private` / `Workspace` in the dropdown and stores `private` / `workspace`, which
  is what every handler receives — `execute` and a realtime `match` alike. `onNoteUpdated` still normalizes
  by hand because `match` gets no `.default()` ([13-runtime-contracts.md](13-runtime-contracts.md)).
- **`onNoteUpdated` and `onNoteDeleted` are two triggers, not one** with an event parameter. They share the
  single webhook registered by `setupRealtimeTriggers`, and each carries the `result` its own event actually
  delivers ([09-realtime-triggers.md](09-realtime-triggers.md)).

## What the shape is showing you

| In the code | Rule |
|---|---|
| Helper `function`s at the bottom, not `const` arrows at the top | [01](01-service-structure.md#module-layout) |
| `helpers.listAllNotes` pages to the end; nothing merely renames `apiRequest` or `files` | [01](01-service-structure.md) |
| `getBoardsDictionary` — id starts `get`, ends `Dictionary`, const matches | [05](05-dictionaries.md) |
| `workspaceIdParam` / `boardIdParam` — one plugin call per line | [03](03-parameters.md) |
| `result` is a sample object, never a zod schema plus `.example()` | [07](07-actions.md) |
| Every `execute` is a block body with an explicit `return` | [07](07-actions.md) |
| `createNote` returns the API's response whole; `listBoardNotes` extends without dropping | [07](07-actions.md) |
| `new Flowrunner.Request(url, method, body)` — never `Request[method](url)` | [04](04-http-requests.md) |
| `Object.hasOwn` at the lookup, on a plain object literal | [16](16-gotchas.md) |
