> ## Documentation Index
> Fetch the complete documentation index at: https://app.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](/learn/workflows/overview) or [agent](/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`, 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",
  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](/learn/triggers/overview#attachment-id) and run history)     |
| `schedule` | Yes      | A cron expression for how often to poll                                                                       |
| `run`      | Yes      | `() => payload`, produces the value checked by filters and passed to the target                               |
| `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](/learn/credentials/use-credentials#resolution-order). To pin a specific instance for a poll, [bind an assignment](/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",
  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 });
```

| 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. |

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](/learn/triggers/schedules). For "run only if X", use a poll; a cron runs the target every tick with no filter.

On deploy, a poll fires immediately; disable the attachment until the workflow is verified (the disabled state survives redeploys).

## 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", 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](/learn/triggers/advanced-triggers)).

## Run a poll on demand

You don't have to wait for the schedule while developing. Trigger a poll tick immediately:

```bash theme={null}
keystroke triggers poll new-inbox
```

This runs `run()` and, if the filters pass, starts the target on the same path the schedule takes. Inspect results with `keystroke triggers runs list new-inbox --workflow <target-slug>` (or `--agent <target-slug>`).

## Next steps

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

  <Card title="Schedules" href="/learn/triggers/schedules">
    Run on a cron schedule with no payload.
  </Card>

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

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