# 08 — Polling Triggers

Register with `ext.addPollingTrigger(props)`. The runtime drives the loop (interval ≥ 30s, auto-noted in
the description), persists `state`, seeds on the first poll, and owns `handleTriggerPollingForEvent`.
Declare **exactly one** mechanism.

| Property | Type | Req | Description |
|---|---|---|---|
| `category`/`id`/`label`/`description` | string | ✓ | `id` is persisted and never changes. |
| `params` | `z.object` | – | Trigger config — omit if it takes none (see [03-parameters.md](03-parameters.md)). |
| `result` | value \| schema \| loader | ✓ | Emitted item shape. |
| `fetch` | fn | – | Feeds `dedupe`/`watermark` (not used with `poll`). |
| `dedupe` \| `watermark` \| `poll` | see below | ✓ | Exactly one. |

| Function | Arguments | Returns |
|---|---|---|
| `fetch` | `{ params, isLearningMode, context, apiRequest, … }` | `items[]` |
| `poll` | `{ params, state, isLearningMode, context, apiRequest, … }` | `{ events, state }` |

## Mechanisms

| Mechanism | Config | Runtime behavior |
|---|---|---|
| `dedupe` (seen-set) | `item => id` (or field path); or `{ by, stateKey='seen', max=500 }` | Keeps a bounded id set under `stateKey`; emits items whose id is new. Covers new-by-id + stateless-newest (head-only `fetch`). |
| `watermark` (marker) | `{ by, stateKey='watermark', sortedDesc? }` — `by` = field path or `(item,{params})=>value` | Stores newest marker, emits items past it, advances. `sortedDesc:true` early-breaks. Covers new-by-date + new-or-updated. |
| `poll` (escape hatch) | fn (above) | Full control; author owns `state` shape. Covers overlap-window+dedup, lag-held cursor, consuming/offset feeds. |

## Lifecycle — `state` before → after \| emits

| Phase | `dedupe` | `watermark` | `poll` |
|---|---|---|---|
| start (no state) | `null → { seen:{…} }` \| `[]` (seed) | `null → { <marker> }` \| `[]` (seed) | `null → seeded` \| `[]` (seed yourself) |
| learningMode | `null → null` \| `[newest]` (reset) | `null → null` \| `[newest]` (reset) | `null → null` \| `[newest]` (reset) |
| timer | `{seen} → {seen+new}` \| `[new]` | `{marker} → {advanced}` \| `[past marker]` | yours → yours \| your delta |

`isLearningMode`: during setup the runtime calls `fetch`/`poll` with `isLearningMode:true`; return a real
(limited) sample; the runtime emits it and does **not** persist state. This is distinct from `result`
(which describes shape only).

## One trigger per event

**Register a separate trigger for each kind of event, each with its own `id`, `label`, `result` sample and
filter `params`.** Do not declare one trigger that takes an event-type parameter and switches inside its own
handler: its `result` then describes none of the shapes it emits, and a flow author has to configure which
event they meant instead of choosing it by name.

Where an API delivers several event kinds through **one consuming endpoint** — a cursor or offset feed that
hands each item to a single reader — separate polling triggers would compete for it, and the one that polls
first takes the update the others never see. That is a reason to deliver those events over
[09-realtime-triggers.md](09-realtime-triggers.md), where one webhook registration fans out to as many
triggers as the service declares, not a reason to write a universal polling trigger. If the API offers no
webhook at all, say so in the service `README.md` and declare the one trigger the endpoint can support.

The handler bag also carries the shared context (see [07-actions.md](07-actions.md)).
