> ## Documentation Index
> Fetch the complete documentation index at: https://docs.os.meetluno.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Polling

> Periodically poll a source and run workflows.

A poll trigger runs on a schedule, calls a `run()` function you provide, and starts a [workflow](https://docs.os.meetluno.ai/learn/workflows/overview.md) or [agent](https://docs.os.meetluno.ai/learn/agents/overview.md) only when that function returns something worth acting on. Use it for sources that don't push webhooks, such as an inbox, an API you check periodically, or a queue.

## Example requests

Ask your coding agent what to watch and when it is worth acting on. It can write the check and the filter.

> "Check our vendor API every hour and run the workflow only when a new invoice is ready to process."

> "Check the support inbox every few minutes and start a triage run when an unread ticket arrives."

> "Check this public dataset daily and kick off a run only when the numbers change."

## Define a poll

Use `definePollSource` with a `slug`, `name`, `description`, a `schedule`, and a `run` function, then attach a target. The `run` function produces the payload; an optional `filter` decides whether that payload is worth a run.

```ts src/triggers/new-inbox.ts theme={null}
import { definePollSource } from "@meetluno/luno/trigger";
import workflow from "../workflows/new-inbox";

export default definePollSource({
  slug: "new-inbox",
  name: "New Inbox",
  description: "Checks the inbox for new emails every five minutes.",
  schedule: "*/5 * * * *",
  run: async () => fetchInbox(),
  filter: (result) => result.emails.length > 0,
}).attach({ workflow });
```

| Option | Required | What it does |
| - | - | - |
| `slug` | Yes | Stable trigger slug (used in the [attachment id](https://docs.os.meetluno.ai/learn/triggers/overview.md#attachment-id) and run history) |
| `name` | Yes | Human-readable label shown in the platform |
| `description` | Yes | What the trigger watches for, shown in the platform |
| `schedule` | Yes | A 5-field cron expression for how often to poll (at most once per minute) |
| `timezone` | No | IANA timezone for schedule wall-clock fields (e.g. `America/New_York`). When omitted, UTC |
| `run` | Yes | `() => payload`, produces the value checked by filters and passed to the target; receives `{ state, setState }` when a [`state` schema](#cursor-state) is declared |
| `filter` | No | `(payload) => boolean`, return `false` to skip this tick |
| `state` | No | Zod schema for cursor state persisted between poll ticks |
| `id` | No | Poll group id; sources sharing an `id` poll together (one `run()` per tick). Cannot be combined with `state`. |

### Use actions inside `run()`

Poll `run()` executes inside an action runner (no steps, no durability), so calling `await myAction.run(input)` auto-resolves the action's credentials (bound to the action slug) much like a workflow step:

```ts theme={null}
run: async () => fetchInbox.run({}),
```

Credentials follow the normal [resolution order](https://docs.os.meetluno.ai/learn/credentials/use-credentials.md#resolution-order). To pin a specific instance for a poll, [bind an assignment](https://docs.os.meetluno.ai/learn/credentials/connect-credentials.md#bind-a-credential-to-a-step-tool-or-poll-action) with `--poll <slug>` — poll consumers are the slugs of the actions called inside `run()`.

## Cursor state

Polls that need to remember what they already processed — a last-checked timestamp, seen message ids, a pagination token — can declare a `state` schema. Luno OS persists that blob on the trigger row and passes it into `run()` each tick:

```ts theme={null}
import { z } from "zod";

export default definePollSource({
  slug: "inbox",
  name: "Inbox",
  description: "Polls the inbox for messages since the last check.",
  schedule: "*/5 * * * *",
  state: z.object({ lastCheckedAt: z.string() }),
  run: async ({ state, setState }) => {
    const since = state?.lastCheckedAt ?? "1970-01-01T00:00:00.000Z";
    const messages = await fetchInbox({ after: since });
    setState({ lastCheckedAt: new Date().toISOString() });
    return messages;
  },
  filter: (messages) => messages.length > 0,
}).attach({ workflow });
```

When a `state` schema is declared, `run` always receives the `{ state, setState }` context; without one, `run()` takes no arguments. `state` itself is still `undefined` on the very first tick.

| API | What it does |
| - | - |
| `state` | Zod schema describing the persisted cursor shape |
| `run({ state, setState })` | `state` is the last committed value (or `undefined` on first run); call `setState(next)` to update it |
| Commit timing | State is saved after a successful `run()` — including when the filter rejects (nothing new to process). If `run()` throws or dispatch fails, the cursor does not advance. |
| Where it persists | State lives on the deployed trigger row. Manual invokes (`luno triggers invoke`) and scheduled ticks both run against the deployed trigger, so the cursor advances on the platform. |

Delivery is **at-least-once**: a failed dispatch retries on the next tick with the same cursor, so the same items may appear again. Workflows that cannot tolerate re-processing should use a durable external dedup log (e.g. upsert a sheet row by thread id) in addition to or instead of platform cursor state.

### When to use which strategy

| Strategy | Good for | Tradeoff |
| - | - | - |
| **Platform cursor** (`state` + `setState`) | "Process items since last check" — timestamps, pagination tokens | At-least-once; re-delivery possible if dispatch fails mid-tick |
| **Durable external log** | Workflows that must never re-process the same item (upsert by id in a sheet/DB) | You manage storage; strongest dedup guarantee |
| **Source-side consumption** | APIs that track read state themselves (`is:unread`, mark-as-read) | Ephemeral — a still-unread item reappears on the next tick |

## How a poll tick works

On each scheduled tick, Luno OS calls `run()`, applies the [filters](#filtering), and, if they pass, starts the target with the payload. If a filter returns `false`, the tick is skipped and no run is created — skipped ticks don't count toward execution limits. The `schedule` uses the same cron format as [schedule triggers](https://docs.os.meetluno.ai/learn/triggers/schedules.md), except polls only accept 5-field expressions (no seconds field) — a poll fires at most once per minute. For "run only if X", use a poll; a cron runs the target every tick with no filter.

One tick produces **one** payload and, when filters pass, **one** run per attached target. Returning an array from `run()` does **not** fan out into one run per item — the whole return value is the payload. Prefer wrapping a batch in an object (for example `{ prs: [...] }`), filter on emptiness (`payload.prs.length > 0`), and use `transform` on the attachment if the workflow expects a different shape. To process items one at a time, loop inside the workflow.

On deploy, a poll fires immediately; disable the attachment until the workflow is verified (the disabled state survives redeploys when the attachment target is unchanged — repointing to a new workflow creates a new enabled attachment).

## Filtering

The most common pattern is "only run when there's new work." Filter the payload before it starts a run:

```ts theme={null}
filter: (result) => result.emails.length > 0,
```

You can chain additional `.filter()` predicates; **all** of them must pass for the tick to fire:

```ts theme={null}
definePollSource({
  slug: "inbox",
  name: "Inbox",
  description: "Checks the inbox for unread emails.",
  schedule: "*/5 * * * *",
  run: fetchInbox,
})
  .filter((r) => r.emails.length > 0)
  .filter((r) => r.emails.some((e) => !e.read))
  .attach({ workflow });
```

Filters are part of the source; they decide *whether* to run. To shape *what* the run receives, use a `transform` on the attachment (see [advanced triggers](https://docs.os.meetluno.ai/learn/triggers/advanced-triggers.md)).

## Invoke a poll on demand

You don't have to wait for the schedule while developing. Invoke a poll tick immediately against the deployed project:

```bash theme={null}
luno triggers invoke new-inbox
luno triggers invoke new-inbox --workflow signup-pipeline   # narrow to one attachment
```

This enqueues the same poll job the schedule uses: `run()` executes, and if the filters pass, the target starts. Inspect results with `luno triggers runs list new-inbox --workflow <target-slug>` (or `--agent <target-slug>`). You can also use **Invoke** on the trigger detail page in the web app.

## Next steps

> **[Advanced triggers](https://docs.os.meetluno.ai/learn/triggers/advanced-triggers.md)**
>
> Transforms, agent prompts, and filtering in depth.

> **[Schedules](https://docs.os.meetluno.ai/learn/triggers/schedules.md)**
>
> Run on a cron schedule (optional static payload).

> **[App events](https://docs.os.meetluno.ai/learn/triggers/app-events.md)**
>
> Poll a connected app's API for new events.

> **[Triggers overview](https://docs.os.meetluno.ai/learn/triggers/overview.md)**
>
> Sources, attach, and attachment ids.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.