Skip to Content
Config SystemDefining Operations

Defining Operations

Operations register HTTP endpoints for custom logic. When triggered — by a user action, a lifecycle hook, a schedule, or an API call — the platform sends a signed payload to your endpoint and records the result.

Operation Interface

interface ApplyConfigOperationInput { key: string; // Unique identifier (e.g., 'sync-data') name: string; // Display name description?: string; // What this operation does icon?: string; // Admin-UI icon key category?: string; // Grouping category endpoint?: string; // HTTP endpoint URL (relative, resolved on push) isActive?: boolean; // Whether the operation is enabled (default: true) timeoutMs?: number; // Per-operation timeout in milliseconds inputSchema?: Record<string, unknown>; // JSON Schema for input outputSchema?: Record<string, unknown>; // JSON Schema for the operation result allowedRoles?: string[]; // Role keys allowed to execute this operation retryPolicy?: Record<string, unknown>; // HTTP-dispatch retry policy precondition?: Precondition; // Gate before execution quotas?: { rules: QuotaRule[] }; // Usage quotas /** Default execution mode: 'sync' (default) or 'async'. */ mode?: 'sync' | 'async'; /** TTL the platform waits for an async callback before timing out (clamped 5m–7d, default 24h). */ callbackTtlSeconds?: number; /** Retry policy applied specifically when the async callback times out. */ callbackTimeoutRetryPolicy?: { maxRetries?: number }; /** Capability strings stamped onto the scoped dispatch token (e.g. 'records:read:<model>'). */ capabilities?: string[]; /** How the operation surfaces: 'mutation' (default), 'query', or 'field'. See Resolver Operations. */ kind?: 'mutation' | 'query' | 'field'; /** Model the resolver field attaches to (kind 'field' only). */ attachedToModel?: string; /** Field path(s) the resolver populates (kind 'field' only). */ parentFields?: string[]; /** Result cache-control for resolver operations (kind 'query'/'field'). */ cacheControl?: { scope: 'PUBLIC' | 'PRIVATE' | 'NO_CACHE'; maxAge?: number }; /** Maps {{secret.NAME}} placeholders to project-owned secrets (resolver operations). */ secretBindings?: Array<{ name: string; secretRef: string }>; /** Outbound request template for resolver operations (kind 'query'/'field'). */ requestTemplate?: { method?: string; pathTemplate?: string; query?: Record<string, string>; headers?: Record<string, string>; bodyTemplate?: string; batchCapable?: boolean; }; /** jq-style transform of the upstream response (resolver operations). */ responseTransform?: string; }

The endpoint field specifies the relative URL path for the operation. When you run foir push, the platform resolves relative endpoints against your deployed service URL — see operationBaseUrl on the top-level config.

Basic Example

import { defineConfig, defineOperation } from '@foir/cli/configs'; export default defineConfig({ key: 'my-app', name: 'My App', operations: [ defineOperation({ key: 'sync-to-cdn', name: 'Sync to CDN', description: 'Pushes published content to the CDN edge network', endpoint: '/sync/cdn', }), ], });

Endpoint URLs

Operation endpoints are relative paths that are resolved against your service’s base URL at push time:

operations: [ { key: 'generate-pdf', name: 'Generate PDF', endpoint: '/generate/pdf', }, { key: 'send-notification', name: 'Send Notification', endpoint: '/notify', }, ]

When the platform triggers an operation, it POSTs a signed payload to the resolved URL. See Operations for details on the request/response contract.

Endpoints must be public

Foir only calls public URLs. The resolved endpoint is checked when you save the operation, and again every time it runs. An address that is not publicly routable is refused, including loopback, private ranges such as 10.0.0.0/8, link-local addresses, cloud metadata addresses, and any .internal hostname.

Public does not mean unprotected. What secures your endpoint is the signed token every dispatch carries in X-Foir-Token, which your handler verifies before doing anything else, so a route that skips that check is open to anyone who finds its URL. See Authentication and Capabilities for the contract and Building Operations for the one-line SDK path.

The rule here is a separate matter, and it protects Foir rather than your service: an operation endpoint tells Foir’s workers to make a request, so restricting them to public addresses stops an operation being pointed back at Foir’s own internal network.

Saving an operation whose endpoint resolves to one of those fails with a validation error. An endpoint that only starts resolving to one later is refused at the moment it would be called, and the execution is marked failed rather than retried, because no retry can turn a private address into a public one.

In practice this means operationBaseUrl has to point at a deployed service the internet can reach. While you are developing, expose your local service through a public tunnel and point operationBaseUrl at the tunnel’s URL rather than at localhost.

The same rule applies everywhere Foir calls a URL you configure, so it also covers app manifest and middleware URLs, web push endpoints, and the discovery URL of a customer identity provider.

Categorizing Operations

Use the category field to group related operations:

operations: [ { key: 'generate-css', name: 'Generate Theme CSS', description: 'Generates Tailwind v4 CSS from design tokens', endpoint: '/generate-css', category: 'design-system', }, { key: 'generate-tokens', name: 'Generate Token File', description: 'Exports design tokens as JSON', endpoint: '/generate-tokens', category: 'design-system', }, ]

Execution Modes

Operations declare a default execution mode. Callers can override at dispatch time when the operation supports the requested mode.

ModeBehaviourUse For
syncFoir holds the connection until the endpoint responds. Result returned to the caller inline.Sub-minute deterministic transforms, validation, lookups.
asyncFoir dispatches and gets 202 Accepted back. Endpoint queues the work, then calls back to the public API to report progress + terminal state.Long-running work, progress reporting, anything that depends on a slow upstream you don’t want blocking the dispatch.
defineOperation({ key: 'full-export', name: 'Full Data Export', endpoint: '/exports/full', mode: 'async', callbackTtlSeconds: 3600, // 1 hour to call back callbackTimeoutRetryPolicy: { maxRetries: 2 }, })

The callbackTtlSeconds field controls how long the platform waits for a callback before marking the execution timed_out. If unset, the platform falls back to the project’s default callback TTL, then to 24 hours. Clamped to [5m, 7d].

callbackTimeoutRetryPolicy is distinct from retryPolicy: retryPolicy governs HTTP dispatch failures (network errors, 5xx); callbackTimeoutRetryPolicy governs callback timeouts. Splitting them lets you retry dispatch failures aggressively while being conservative about callback timeouts — re-running an hour-long job that timed out tends to burn another hour.

See Operations for the full async-callback handshake.

Resolver Operations

By default an operation is a mutation (kind: 'mutation') — an executeXxx root mutation dispatched through the worker queue. Two other kinds turn an operation into a GraphQL resolver that calls an upstream endpoint and shapes the result inline:

kindSurfaces asResolved
mutation (default)executeXxx root mutationWorker queue dispatch
queryRoot Query fieldSynchronously, in-process
fieldResolver field on attachedToModelBatched per request

Resolver operations (query / field) use requestTemplate to shape the outbound HTTP request and responseTransform to reshape the upstream response:

defineOperation({ key: 'weather', name: 'Weather Lookup', kind: 'field', attachedToModel: 'city', // attach the resolver field to this model (field only) parentFields: ['weather'], // field path(s) this resolver populates (field only) cacheControl: { scope: 'PUBLIC', maxAge: 300 }, secretBindings: [ { name: 'WEATHER_KEY', secretRef: 'weather_api_key' }, ], requestTemplate: { method: 'GET', pathTemplate: '/v1/forecast?city={{parent.name}}', headers: { Authorization: 'Bearer {{secret.WEATHER_KEY}}' }, batchCapable: true, // one upstream request for the deduped input set }, responseTransform: '.current.temp_c', // jq-style transform of the upstream body capabilities: ['records:read:city'], })
FieldApplies toDescription
kindall'mutation' (default), 'query', or 'field'.
attachedToModelfieldModel key the resolver field attaches to.
parentFieldsfieldField path(s) under attachedToModel this resolver populates.
cacheControlquery / field{ scope: 'PUBLIC' | 'PRIVATE' | 'NO_CACHE', maxAge? } — result caching.
secretBindingsquery / fieldMaps {{secret.NAME}} placeholders to project-owned secrets.
requestTemplatequery / fieldShapes the outbound request. Variables: {{input.X}}, {{parent.X}}, {{secret.NAME}}, {{context.X}}.
responseTransformquery / fieldjq-style transform applied to the upstream response.
capabilitiesallCapability strings stamped onto the scoped dispatch token (e.g. records:read:<model>).

Active/Inactive Toggle

Disable an operation without removing it by setting isActive to false:

{ key: 'legacy-sync', name: 'Legacy Sync', description: 'Deprecated sync endpoint', endpoint: '/sync/legacy', isActive: false, }

Inactive operations cannot be triggered by hooks, schedules, or API calls. Set isActive back to true (or remove the field) to re-enable.

Real-World Example

From the Cloudflare KV Redirect extension — three operations for syncing, checking status, and tearing down:

operations: [ { key: 'redirect-sync', name: 'Sync Redirects to Edge', description: 'Syncs redirect records to Cloudflare KV', endpoint: '/sync/all', }, { key: 'redirect-sync-status', name: 'Get Sync Status', description: 'Returns current sync metadata for display in the editor', endpoint: '/sync/status', }, { key: 'redirect-undeploy', name: 'Undeploy Edge Redirects', description: 'Tear down Cloudflare Worker and KV namespace', endpoint: '/deploy/destroy', }, ]

Preconditions

A precondition gates whether an operation can execute at all. It evaluates before execution and returns a FAILED_PRECONDITION error if the check fails. Use preconditions for state-based gating: “is this user allowed to do this?”

There are two precondition shapes:

Segment Precondition

Blocks execution unless the customer belongs to a named segment:

defineOperation({ key: 'ai-suggest', name: 'AI Suggest', endpoint: '/suggest', precondition: { segmentKey: 'pro_users', message: 'AI suggestions are available on the Pro plan. Upgrade to unlock.', }, })

If the customer is not a member of the pro_users segment, the platform returns the message as the error. If message is omitted, a generic “operation precondition not met” error is returned.

Expression Precondition

Blocks execution unless a rule expression evaluates to true against the operation input and context:

defineOperation({ key: 'publish-post', name: 'Publish Post', endpoint: '/publish', precondition: { type: 'condition', left: { type: 'field', path: 'status' }, operator: 'equals', right: { type: 'literal', value: 'draft' }, }, })

Expression preconditions support the same operators and grouping as segment rules (AND/OR groups, field/context references, etc.).

Quotas

A quota meters how many times an operation can be executed within a time window. It evaluates after preconditions pass and returns a RATE_LIMITED error when the limit is reached. Use quotas for usage-based metering: “how many times can this user do this?”

defineOperation({ key: 'ai-suggest', name: 'AI Suggest', endpoint: '/suggest', quotas: { rules: [ { points: 50, duration: 86400, scope: 'customer' }, ], }, })

Quota Rule Fields

FieldDescription
pointsMax executions in the window. 0 means unlimited.
durationWindow length in seconds (86400 = 1 day).
scopeWho the counter tracks: "customer", "user", or "tenant".
segmentKeyTarget a specific segment. Omit for a fallback rule.
messageCustom error when quota is exhausted (e.g., an upgrade CTA).
conditionOptional rule expression to conditionally apply the rule.
expiresAtISO 8601 date after which the rule is ignored.

Rule Ordering

Rules are evaluated top-to-bottom, first match wins. A rule matches if:

  1. It has not expired
  2. The customer belongs to the rule’s segment (or the rule has no segmentKey)
  3. The rule’s condition evaluates to true (or the rule has no condition)

Once a rule matches, it is enforced and no further rules are checked. If no rule matches, execution is allowed.

Tiered Pattern: Pro Unlimited / Free Metered

The most common pattern is a segment-gated unlimited rule followed by a metered fallback. Pro customers match the first rule and skip metering entirely; everyone else falls through to the metered rule:

defineOperation({ key: 'ai-suggest', name: 'AI Suggest', endpoint: '/suggest', quotas: { rules: [ // Rule 0: Pro users — unlimited { segmentKey: 'pro', points: 0, duration: 86400, scope: 'customer' }, // Rule 1: Everyone else — 10/day with upgrade CTA { points: 10, duration: 86400, scope: 'customer', message: "You've used your 10 free AI suggestions today. Upgrade to Pro for unlimited.", }, ], }, })

Precondition vs. Quota

PreconditionQuota
PurposeState-based gatingUsage-based metering
Question”Is this allowed?""How many times?”
Error codeFAILED_PRECONDITIONRATE_LIMITED
Typical usePaywalls, feature flags, role checksRate limits, free-tier caps
EvaluationBefore executionAfter precondition passes

You can use both on the same operation. For example, a precondition that requires a logged-in customer, plus a quota that meters usage for free-tier customers.

Connecting Operations to Other Resources

Operations are referenced by key from other config resources:

  • Hooks trigger operations on lifecycle events. See Defining Hooks.
  • Schedules trigger operations on a cron schedule. See Defining Schedules.
  • Operations can also be triggered manually from the admin dashboard or via the Operations API.

Next Steps

Last updated on