Operations
Operations let you extend Foir with custom logic by registering HTTP endpoints. When triggered, Foir sends a standardized payload to your endpoint, signed with a short-lived scoped token, and waits for (or polls) the result.
Overview
Every project has unique needs beyond content management — AI-powered text summarization, external system syncs, custom validation, event notifications. Operations provide a standard contract for all of these. You build a service, deploy it anywhere, and register the URL in Foir. The platform handles execution tracking, retries, scoped-token authentication, and access control.
How It Works
Trigger fires → Foir POSTs an OperationPayload to your endpoint → Your endpoint returns the result (sync) or 202 + later callback (async) → Foir records the execution- A trigger fires (user clicks a button, a lifecycle event occurs, a schedule runs, or an API call is made)
- Foir signs a short-lived token with the operation’s declared capabilities and POSTs the OperationPayload to your endpoint with
X-Foir-TokenandX-Foir-Contextheaders - Your endpoint verifies the token, processes the request, and either returns a synchronous result or returns
202 Acceptedand reports back later via the callback API - Foir records the execution result for audit and tracking
Touch Points
Operations can be triggered from different places in the platform:
| Touch Point | When It Fires | What It Receives |
|---|---|---|
| UI | User clicks a button on a record in the admin | The full record context |
| Field | User triggers it on a specific field in the editor | The field value + full record context |
| Lifecycle | Automatically on create, update, publish, etc. | The full record context |
| Schedule | Cron expression matches the current time | A schedule payload (no record context) |
| API | External call via the public API | User-provided input |
Execution Modes
Operations declare a default mode. Callers can override at dispatch time when the operation supports both.
| Mode | Behaviour | Use For |
|---|---|---|
sync | Foir holds the connection until your endpoint responds (≤ 60 s). Result is returned to the caller inline. | Short, deterministic transforms — AI summaries under a minute, validation, lookups. |
async | Foir POSTs the dispatch and gets back 202 Accepted. Your endpoint queues the work, then calls back to report progress, complete, fail, or cancelled. The execution tracks state until terminal. | Long-running work — large data syncs, AI jobs that exceed the sync wall clock, anything you want progress reporting on. |
Async mode uses a callback handshake: every async dispatch carries a scoped callback token your endpoint uses to report back. The platform tracks the execution to a terminal state and dispatches OPERATION_COMPLETED, OPERATION_FAILED, OPERATION_CANCELLED, or OPERATION_TIMED_OUT lifecycle events when it gets there.
The HTTP Contract
Every operation endpoint speaks the same language.
Request headers
| Header | Description |
|---|---|
X-Foir-Token | Signed token scoped to this dispatch — verify it with the SDK’s verifyScopedToken against https://api.foir.io/.well-known/jwks.json. Carries the declared capabilities. |
X-Foir-Context | Semicolon-separated key=value pairs: project, app, operation, execution_id, triggered_by (hook / cron / manual / api), and any caller-supplied values. |
Content-Type | application/json |
User-Agent | foir-operations/1.0 |
Request body (OperationPayload)
{
"operationKey": "ai-summarize",
"trigger": {
"type": "field",
"fieldKey": "description",
"fieldType": "content"
},
"content": "The full field value being operated on...",
"input": { "maxLength": 200 },
"record": {
"id": "rec_abc123",
"modelKey": "product",
"versionId": "ver_def456",
"data": { "title": "Widget Pro", "description": "..." },
"metadata": { "naturalKey": "widget-pro" }
},
"context": {
"projectId": "project_...",
"tenantId": "tenant_...",
"userId": "user_...",
"locale": "en",
"timestamp": "2026-04-28T10:30:00.000Z"
},
"callback": {
"token": "eyJhbGciOiJFZERTQS...",
"gqlEndpoint": "https://api.foir.io/graphql",
"expiresAt": "2026-04-29T10:30:00.000Z",
"mutations": {
"complete": "completeAiSummarizeExecution",
"fail": "failAiSummarizeExecution",
"progress": "reportAiSummarizeProgress",
"cancel": "cancelAiSummarizeExecution"
}
}
}The callback field is present only on async dispatches — the bearer token your endpoint uses to report back. On sync dispatches it’s omitted (you return the result inline).
Response — sync mode
Your endpoint returns the result body directly:
Success:
{
"success": true,
"result": "A concise summary of the widget...",
"metadata": { "model": "gpt-4", "tokens": 85 }
}Error:
{
"success": false,
"error": {
"code": "UPSTREAM_ERROR",
"message": "AI service returned 429: rate limited"
}
}Response — async mode
Your endpoint returns 202 Accepted immediately and queues the work. When work finishes, report back through the public GraphQL API using the bearer token from the callback field of the payload.
The Editor SDK ships a typed createCallbackClient helper that handles this for you — call cb.progress({ pct, message }) while work is running, then cb.complete(result) or cb.fail(code, message) when you’re done. See Server API Reference.
If you’d rather call the GraphQL API directly: each operation gets a typed complete{Op}Execution, fail{Op}Execution, report{Op}Progress, and cancel{Op}Execution mutation generated from its declared output schema. The callback.mutations block in the dispatch payload tells you the exact mutation names for that operation; callback.token is the Bearer token to send with each request.
Authentication and Capabilities
Every dispatch carries a scoped token in X-Foir-Token. Your endpoint verifies it against the platform’s JWKS endpoint at https://api.foir.io/.well-known/jwks.json — the SDK’s verifyScopedToken handles this for you. The verified claims tell you which tenant, project, and app the dispatch belongs to, and which capabilities it carries.
The capability set determines what your endpoint can do when it calls back to the public API. Capabilities use the form <resource>:<action>[:<scope>] — for example records:read:product, records:write:order, credentials:read, status:write, files:write. Bare records:read / records:write (no model suffix) is rejected at install — operations always declare which specific models they touch.
The legacy HMAC X-Foir-Signature header is no longer used for operation dispatches. The SDK’s verifyOperationSignature / verifyAndParseOperation helpers are deprecated and will be removed in @eide/foir-editor-sdk@0.8.0. Use verifyScopedToken for new code.
In the Admin
Creating an Operation
- Go to Automation > Operations
- Click Create Operation
- Configure:
- Key — unique identifier (e.g.,
ai-summarize) - Name — display name
- Endpoint URL — where Foir will POST the payload
- Mode —
syncorasync - Capabilities — the scopes the dispatch token needs
- Touch Points — which triggers are enabled
- Description — what this operation does
- Key — unique identifier (e.g.,
- Save
Execution Tracking
Every operation execution is recorded with:
- Status —
PENDING,RUNNING,COMPLETED,FAILED,CANCELLED, orTIMED_OUT - Duration — how long the execution took
- Result or error — the response from your endpoint (or callback)
- Trigger context — how and where it was invoked
- Retry count — how many attempts were made
- Progress — for async operations that report progress
View execution history in Automation > Operations > Executions.
Dead Letters
Failed executions that have exhausted all retries appear in the dead-letter queue. From there you can inspect the error, retry the execution, or dismiss it.
Via the CLI
# List all operations
foir operations list
# Get an operation by key
foir operations get <key>
# Create an operation
foir operations create --data '{
"key": "ai-summarize",
"name": "AI Summarize",
"endpoint": "https://your-service.com/summarize",
"mode": "sync"
}'
# Execute an operation
foir operations execute --data '{
"operationKey": "ai-summarize",
"input": { "maxLength": 200 },
"content": "Text to summarize..."
}'
# View execution stats
foir operations stats
# List dead-letter operations
foir operations dead-letters
# Retry a dead letter
foir operations retry-dead-letter <id>
# Dismiss a dead letter
foir operations dismiss-dead-letter <id>For app-owned operations, use foir apps trigger <appName> <operationKey> instead — see CLI Commands.
Via the API
Execute an Operation
mutation ExecuteOperation {
publicExecuteOperation(input: {
operationKey: "ai-summarize"
input: {
maxLength: 200
tone: "professional"
}
content: "Your long text content to summarize goes here..."
mode: SYNC # or ASYNC; defaults to the operation's declared mode
}) {
success
executionId
result
durationMs
error {
code
message
}
}
}Poll for Results
For long-running operations, use the execution ID to check status:
query GetExecution($id: ID!) {
publicOperationExecution(id: $id) {
id
operationKey
status
progress
result
error {
code
message
}
durationMs
completedAt
}
}Execution statuses: PENDING, RUNNING, COMPLETED, FAILED, CANCELLED, TIMED_OUT.
Subscribe for Real-Time Updates
Instead of polling, subscribe via WebSocket or SSE for execution-state changes — see Real-time and Subscriptions. The jobs:{executionId} channel emits progress and terminal events.
Cancel an Execution
mutation CancelExecution($id: ID!) {
publicCancelOperationExecution(id: $id) {
id
operationKey
status
}
}Polling Example
async function waitForResult(executionId: string) {
const MAX_ATTEMPTS = 30;
const POLL_INTERVAL = 2000;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const { data } = await client.query({
query: GET_EXECUTION,
variables: { id: executionId },
fetchPolicy: 'network-only',
});
const execution = data.publicOperationExecution;
if (execution.status === 'COMPLETED') return execution.result;
if (execution.status === 'FAILED') throw new Error(execution.error?.message);
if (execution.status === 'CANCELLED') throw new Error('Operation was cancelled');
if (execution.status === 'TIMED_OUT') throw new Error('Operation callback timed out');
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
throw new Error('Operation timed out waiting for result');
}Config System
Operations can be defined in foir.config.ts using defineOperation. See Operation Configuration for the full reference, including mode, callbackTtlSeconds, callbackTimeoutRetryPolicy, precondition, and quotas.
Examples
AI Summarization (sync)
Register a sync operation that calls an AI service to summarize content. Attach it as a field-level touch point on content or text fields. Users click “Summarize” in the editor and the result is written back to the field.
Long-Running Export (async)
Register an async operation for jobs that take minutes. Your endpoint returns 202 immediately, queues the work in your own background runner, and posts reportOpProgress updates as it goes. When done, call completeOpExecution with the final artifact URL. The admin UI shows live progress; lifecycle hooks fire OPERATION_COMPLETED so downstream automations can chain.
Contact Form Handler (sync, API-triggered)
Expose an API-triggered operation that accepts form submissions and forwards them to your CRM:
mutation SubmitContactForm {
publicExecuteOperation(input: {
operationKey: "contact-form-handler"
input: {
name: "Jane Doe"
email: "jane@example.com"
message: "Question about your enterprise plan..."
}
}) {
success
executionId
}
}External Data Sync (async, lifecycle-triggered)
Use a lifecycle-triggered operation to push content changes to an external system whenever records are published. Declare async mode so a slow upstream doesn’t block publishing. The hook fires, your endpoint accepts the dispatch with 202, queues the work, and the eventual completion fires an OPERATION_COMPLETED event you can hook in turn.
Best Practices
- Verify the scoped token — always validate
X-Foir-Tokenagainst the platform JWKS before processing the request. - Pick the right mode — sync for sub-minute deterministic work, async for anything that can run long, anything you want progress on, or anything that depends on a slow upstream.
- Return quickly in async mode — your endpoint should ack with 202 in milliseconds. Do the actual work in your own background queue and call back when done.
- Handle retries idempotently — your endpoint may receive the same payload more than once if a network blip caused Foir to retry.
- Use the callback token’s capabilities — instead of holding a long-lived
FOIR_API_KEYin your service, use the bearer token bundled in the dispatch envelope for any reads/writes the operation needs. - Monitor dead letters — check the dead-letter queue regularly for failed executions.
- Use appropriate touch points — only enable the triggers your operation actually needs.