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 };
}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 '@eide/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.
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.
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 FailedPrecondition 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 ResourceExhausted 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 | FailedPrecondition | ResourceExhausted |
| 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