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 user 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 user 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 user 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
Use geni workflow preview-filter to run the published gate, or add --draft to test the local filter before publishing:
geni workflow preview-filter --draft --trigger incoming-order --payload '{"total":500}'The preview returns the decision, transformed input and logs. It uses live credentials and persists dedup writes. geni workflow test tests the body separately and bypasses the gate.
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.