> ## Documentation Index
> Fetch the complete documentation index at: https://keystroke.ai/docs/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](/docs/learn/workflows/overview) or [agent](/docs/learn/agents/overview) 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 "@keystrokehq/keystroke/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](/docs/learn/triggers/overview#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 a workflow-style action runner (no durable replay), so calling `await myAction.run(input)` auto-resolves the action's credentials the same way a workflow step would:

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

Credentials follow the normal [resolution order](/docs/learn/credentials/use-credentials#resolution-order). To pin a specific instance for a poll, [bind an assignment](/docs/learn/credentials/connect-credentials#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. Keystroke 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 (`keystroke 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, Keystroke 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](/docs/learn/triggers/schedules), 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](/docs/learn/triggers/advanced-triggers)).

## 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}
keystroke triggers invoke new-inbox
keystroke 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 `keystroke 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

<CardGroup cols={2}>
  <Card title="Advanced triggers" href="/docs/learn/triggers/advanced-triggers">
    Transforms, agent prompts, and filtering in depth.
  </Card>

  <Card title="Schedules" href="/docs/learn/triggers/schedules">
    Run on a cron schedule (optional static payload).
  </Card>

  <Card title="App events" href="/docs/learn/triggers/app-events">
    Poll a connected app's API for new events.
  </Card>

  <Card title="Triggers overview" href="/docs/learn/triggers/overview">
    Sources, attach, and attachment ids.
  </Card>
</CardGroup>
