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.
| Mode | Behaviour | Use For |
|---|---|---|
sync | Foir holds the connection until the endpoint responds. Result returned to the caller inline. | Sub-minute deterministic transforms, validation, lookups. |
async | Foir 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:
kind | Surfaces as | Resolved |
|---|---|---|
mutation (default) | executeXxx root mutation | Worker queue dispatch |
query | Root Query field | Synchronously, in-process |
field | Resolver field on attachedToModel | Batched 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'],
})| Field | Applies to | Description |
|---|---|---|
kind | all | 'mutation' (default), 'query', or 'field'. |
attachedToModel | field | Model key the resolver field attaches to. |
parentFields | field | Field path(s) under attachedToModel this resolver populates. |
cacheControl | query / field | { scope: 'PUBLIC' | 'PRIVATE' | 'NO_CACHE', maxAge? } — result caching. |
secretBindings | query / field | Maps {{secret.NAME}} placeholders to project-owned secrets. |
requestTemplate | query / field | Shapes the outbound request. Variables: {{input.X}}, {{parent.X}}, {{secret.NAME}}, {{context.X}}. |
responseTransform | query / field | jq-style transform applied to the upstream response. |
capabilities | all | Capability 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
| Field | Description |
|---|---|
points | Max executions in the window. 0 means unlimited. |
duration | Window length in seconds (86400 = 1 day). |
scope | Who the counter tracks: "customer", "user", or "tenant". |
segmentKey | Target a specific segment. Omit for a fallback rule. |
message | Custom error when quota is exhausted (e.g., an upgrade CTA). |
condition | Optional rule expression to conditionally apply the rule. |
expiresAt | ISO 8601 date after which the rule is ignored. |
Rule Ordering
Rules are evaluated top-to-bottom, first match wins. A rule matches if:
- It has not expired
- The customer belongs to the rule’s segment (or the rule has no
segmentKey) - 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
| Precondition | Quota | |
|---|---|---|
| Purpose | State-based gating | Usage-based metering |
| Question | ”Is this allowed?" | "How many times?” |
| Error code | FAILED_PRECONDITION | RATE_LIMITED |
| Typical use | Paywalls, feature flags, role checks | Rate limits, free-tier caps |
| Evaluation | Before execution | After 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
- Operations Concepts — How operations work, the HTTP contract, and signing
- Building Operations — Step-by-step guide to building an operation endpoint
- Defining Hooks — Trigger operations on content events
- Defining Schedules — Trigger operations on a cron schedule