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.
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'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.
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
{ decision: 'skip', reason }ends the run as Filtered out.reasonis required, and it is the entire story the operator gets about why their workflow stayed quiet.{ decision: 'run', reason?, input? }lets the workflow run.inputreplaces 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. Omitinputto pass the payload through unchanged.context.trigger.payloadalways keeps the raw event.
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 does not cover. Never write a polling loop inside the workflow body.
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
skipsaying 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
- It must be declared in
workflow.json. Both halves or neither: validation rejects afilterblock 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.getConstthrows 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.
fetchand 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
The CLI cannot preview a filter
geni 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,
both of which have the tool.
Once it is live, the gate is verified when you have seen a Filtered out execution in geni workflow executions, or a run whose input is the filter's own shape rather than the raw trigger payload.
Skills
The full contract for a skill. A SKILL.md instruction bundle that agents load as context to learn how to do a kind of work — authored and published through the same resource lifecycle as workflows and apps.
geni resource create
Create a resource and scaffold its starting files into a local directory.