# Filters

> An optional filter.ts decides whether a workflow runs at all, and on what. Use it to gate a noisy trigger, or to build a trigger for a source that has none.

Source: https://docs.generalinput.com/cli/resource/filter



A workflow may carry an optional `filter.ts`: a cheap, deterministic gate that runs before the workflow does and returns a decision instead of doing the work. It is bundled and run exactly like a [code workflow](/cli/resource/code)'s `main.ts`, with the same `WorkflowContext`, the same credentials and consts, the same databases and stores. Both code and agent workflows can have one.

```ts
import type {
  WorkflowContext,
  WorkflowFilterDecision,
} from '@general-input/core'

export default async function filter(
  input: unknown,
  context: WorkflowContext
): Promise<WorkflowFilterDecision> {
  const minValue = Number(context.getConst('minOrderValue'))
  const order = input as { total?: number; id?: string }

  if (typeof order.total !== 'number' || order.total < minValue) {
    return {
      decision: 'skip',
      reason: `Order ${order.id} is $${order.total}, minimum is $${minValue}`,
    }
  }
  return { decision: 'run' }
}
```

## The decision [#the-decision]

* `{ decision: 'skip', reason }` ends the run as **Filtered out**. `reason` is required, and it is the entire story the operator gets about why their workflow stayed quiet.
* `{ decision: 'run', reason?, input? }` lets the workflow run. `input` **replaces** the trigger payload, so a filter that already fetched what the workflow needs can hand back a small resolved shape and delete the lookups the body would repeat. Omit `input` to pass the payload through unchanged. `context.trigger.payload` always keeps the raw event.

## Why bother [#why-bother]

**To gate a real trigger.** A webhook or poll fires often and most events do not deserve a run. A code run costs a fraction of a cent; an agent run costs many times that before it does anything. Judge the event in the filter and the agent never wakes up.

**To build a trigger that does not exist.** Pair `filter.ts` with a `cron` trigger and the filter *is* the trigger: it polls whatever source has no first-party provider, dedups against a database or store slot, and returns `run` with the new items. This is the answer for any source [`geni trigger list`](/cli/trigger/list) does not cover. Never write a polling loop inside the workflow body.

## Dedup, for a custom trigger [#dedup-for-a-custom-trigger]

State lives in a `user-database` or `user-storage` slot the filter declares, and only the filter touches it. Pick the cheapest strategy the source supports:

| The source gives you             | Keep                               | Check           |
| -------------------------------- | ---------------------------------- | --------------- |
| A monotonic id or timestamp      | one high-water mark                | `id > lastSeen` |
| Stable ids, no ordering          | a bounded set of recent ids        | `!seen.has(id)` |
| Neither, such as a rendered page | a hash of the content that matters | `hash !== last` |

Three rules make the difference between a working custom trigger and an incident:

* **The filter creates its own tables.** Open every run with `CREATE TABLE IF NOT EXISTS`. A fresh slot is an empty database, and a filter that throws fails open and runs the body on every tick.
* **The first tick must not fire.** A new filter has no state, so everything that already existed looks new. Record what you find and return `skip` saying so ("First check: recorded 412 open tickets, watching from here"). Firing on the backlog is how a custom trigger's first run becomes 400 runs.
* **Record before returning `run`, and record exactly what you hand the body.** An item is delivered at most once. If losing one is unacceptable, write a pending marker the body clears on success.

## Rules [#rules]

* **It must be declared in `workflow.json`.** Both halves or neither: validation rejects a `filter` block with no file, and a file with no block.
* **Every threshold is a const, never a literal.** A hardcoded number is a knob the operator cannot reach. `context.getConst` throws for any key the document does not declare, so every key you read is visible to them.
* **Reasons name the entity and the value that failed.** "Ticket #4821 is priority=low, minimum is high", never "Filtered" or "Did not match". The operator is scanning near-identical rows for the one that went wrong.
* **Read-only and fast.** The one sanctioned write is the dedup state it keeps for itself. It is hard-stopped at 60 seconds.
* **No npm dependencies.** `fetch` and the Node standard library only.
* **No `chat-completion`, and no LLM of any kind.** Burning tokens inside the thing built to save tokens defeats the point.
* **A filter failure runs the workflow.** If it throws, times out, or will not build, the run proceeds and the row says why. That is the platform's job; never re-implement it.

## Testing one [#testing-one]

<Callout type="warn" title="The CLI cannot preview a filter">
  [`geni workflow test`](/cli/resource/workflow/test) runs the body against the
  payload you pass and never invokes the gate, so a green test says nothing
  about the filter. There is no `geni` command that runs one. Preview it from a
  General Input chat in the dashboard, or over an [MCP connector](/mcp/tools),
  both of which have the tool.
</Callout>

Once it is live, the gate is verified when you have seen a **Filtered out** execution in [`geni workflow executions`](/cli/resource/workflow/executions), or a run whose input is the filter's own shape rather than the raw trigger payload.
