Skip to main content
A poll trigger runs on a schedule, calls a run() function you provide, and starts a workflow or agent 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.
src/triggers/new-inbox.ts
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 });
OptionRequiredWhat it does
slugYesStable trigger slug (used in the attachment id and run history)
scheduleYesA cron expression for how often to poll
runYes() => payload, produces the value checked by filters and passed to the target
filterNo(payload) => boolean, return false to skip this tick
stateNoZod schema for cursor state persisted between poll ticks
idNoPoll 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:
run: async () => fetchInbox.run({}),
Credentials follow the normal resolution order. To pin a specific instance for a poll, bind an assignment 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:
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 });
APIWhat it does
stateZod 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 timingState 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

StrategyGood forTradeoff
Platform cursor (state + setState)“Process items since last check” — timestamps, pagination tokensAt-least-once; re-delivery possible if dispatch fails mid-tick
Durable external logWorkflows that must never re-process the same item (upsert by id in a sheet/DB)You manage storage; strongest dedup guarantee
Source-side consumptionAPIs 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, 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. 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:
filter: (result) => result.emails.length > 0,
You can chain additional .filter() predicates; all of them must pass for the tick to fire:
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).

Run a poll on demand

You don’t have to wait for the schedule while developing. Trigger a poll tick immediately:
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

Advanced triggers

Transforms, agent prompts, and filtering in depth.

Schedules

Run on a cron schedule with no payload.

App events

Poll a connected app’s API for new events.

Triggers overview

Sources, attach, and attachment ids.