Building Operations
This guide walks you through building, deploying, and registering a custom operation endpoint. By the end, you’ll have a working service that receives requests from Foir, verifies the dispatch token, runs your logic, and returns or callbacks a result.
What You’ll Build
A standalone Node.js (or Cloudflare Workers) service that:
- Receives scoped-token authenticated dispatches from the platform on
X-Foir-Token - Verifies the token against the platform’s JWKS endpoint
- Runs your custom logic (AI, data sync, etc.)
- Returns an
OperationResultinline (sync mode) or queues the work and reports back via a callback client (async mode)
Step 1: Scaffold the project
Use the Foir CLI to generate a starter project. The create-config command scaffolds a monorepo with a Vite UI app and a Hono API server already wired up for token verification:
npx @foir/cli create-config my-summarizer --type workflowThe --type flag accepts custom-editor, workflow, or widget. For an operation, workflow is the best fit. Omit it to be prompted.
Project structure:
my-summarizer/
package.json
foir.config.ts
ui/
package.json
src/
main.tsx
App.tsx
index.css
.env.example
api/
package.json
src/
index.ts # Hono entrypoint (port 3002)
routes/
operations.ts # Operation handlers + scoped-token verification
webhooks.ts # Inbound webhook handlers (if needed)
health.ts
.env.examplecd my-summarizer
npm install
cp ui/.env.example ui/.env.local
cp api/.env.example api/.env.local
npm run devThe UI starts on http://localhost:3001, the API on http://localhost:3002.
Step 2: Implement your sync operation
Open api/src/routes/operations.ts. The scaffold already includes the dispatch-token middleware described in Step 4. Add your handler:
import type { OperationPayload, OperationResult } from '@foir/sdk/server';
async function handleSummarize(
payload: OperationPayload
): Promise<OperationResult> {
const { trigger, content, input } = payload;
if (trigger.type !== 'field' || typeof content !== 'string') {
return {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'This operation requires a text field value',
},
};
}
const maxLength = (input.maxLength as number) ?? 200;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [
{
role: 'user',
content: `Summarize in under ${maxLength} characters:\n\n${content}`,
},
],
}),
});
const data = await response.json();
return {
success: true,
result: data.choices[0].message.content,
metadata: {
model: 'gpt-4',
tokens: data.usage.total_tokens,
},
};
}Step 3: (Optional) async mode for long-running work
If your operation can run longer than a minute (large exports, image generation, batch jobs), declare it as mode: 'async' and respond with 202 Accepted. Use createCallbackClient to report progress and the terminal result:
import { createCallbackClient, type OperationPayload } from '@foir/sdk/server';
app.post('/operations/full-export', async (c) => {
const payload = (await c.req.json()) as OperationPayload;
if (!payload.callback || !payload.executionId) {
return c.json({ error: 'expected async dispatch' }, 400);
}
// Queue the work in your own durable runtime
await queue.add('full-export', {
executionId: payload.executionId,
callback: payload.callback,
input: payload.input,
});
return c.json({ accepted: true }, 202);
});
// In your queue worker:
async function processFullExport(job) {
const { executionId, callback, input } = job.data;
const cb = createCallbackClient(callback, {
executionId,
signingSecret: process.env.FOIR_SIGNING_SECRET!,
});
const items = await fetchAllItems();
for (let i = 0; i < items.length; i++) {
if (i % 100 === 0) {
const result = await cb.progress({
pct: Math.floor((i / items.length) * 100),
message: `Exported ${i} of ${items.length}`,
});
if (result.cancelled) return;
}
await exportItem(items[i]);
}
await cb.complete({ exportedCount: items.length });
}The callback client signs each request with FOIR_SIGNING_SECRET (HMAC of the body) and bears the scoped callback token from payload.callback.token. The platform verifies both before accepting the callback.
Step 4: Set up scoped-token verification
Operation dispatches carry a signed JWT in X-Foir-Token. Every route the platform dispatches to must verify it before doing anything else. Your endpoint has to be publicly reachable for the platform to call it — so its security is this verification. A route that skips it is an open endpoint anyone on the internet can call directly, bypassing the operation’s scopes, quotas, and preconditions.
The SDK makes the safe path one line:
import { Hono } from 'hono';
import {
requireScopedToken,
ScopedTokenError,
type ScopedTokenIngress,
} from '@foir/sdk/server';
const app = new Hono<{ Variables: { dispatch: ScopedTokenIngress } }>();
app.use('/operations/*', async (c, next) => {
try {
c.set(
'dispatch',
await requireScopedToken(c.req.raw, {
expectedProjectId: process.env.FOIR_PROJECT_ID,
}),
);
await next();
} catch (err) {
if (err instanceof ScopedTokenError) {
return c.json({ error: err.message }, err.status);
}
throw err;
}
});requireScopedToken:
- Extracts the token from
X-Foir-Token(missing → 401) - Verifies the JWT signature against the cached JWKS (5-minute TTL), validating
iss,exp, andnbf - Rejects a valid token bound to a different project or tenant with 403 when you pass
expectedProjectId/expectedTenantId— always pin these from your deploy’s own config, so a token minted for another project can never drive your handler - Returns the parsed claims, the
tenantId/projectId/appNamesubject, and theX-Foir-Contextcorrelation metadata
JWKS keys are cached automatically per isolate; you don’t need to manage that. (The lower-level verifyScopedToken remains exported if you need custom handling.)
Hardening checklist
- Verify on every platform-called route — including “internal-looking” ones. The dispatch token is the trust anchor: the platform reserves
X-Foir-TokenandX-Foir-Context, and operation request templates cannot override them. - Pin
expectedProjectIdfrom your own environment config, not from anything in the request. - Never log interpolated secrets. Secret bindings arrive already hydrated into headers/body — keep them out of logs and error reports.
- Respond within the operation’s
timeout_msand keep responses under the platform’s 5 MB cap; oversized or slow responses fail the execution. - Capability checks: for record-touching handlers, honor the claim’s model allow-lists (
recordsReadModels(claims)/recordsWriteModels(claims)) rather than assuming full access.
What about FOIR_SIGNING_SECRET?
The legacy HMAC-based dispatch path is retired. You no longer need FOIR_SIGNING_SECRET for inbound verification.
You do still need it for outbound callbacks in async mode — the callback client HMACs each callback request body with the secret. The CLI publishes it to your service automatically when you run foir push.
Local testing without the platform
For local iteration, mock the verifier:
const verify = process.env.FOIR_VERIFY === 'false'
? async () => ({ sub: 'test|test|test', cap: ['records:read:product'] } as any)
: (token: string) => verifyScopedToken(token, JWKS_URL);Then FOIR_VERIFY=false npm run dev.
Step 5: Test locally
curl -X POST http://localhost:3002/operations/summarize \
-H "Content-Type: application/json" \
-H "X-Foir-Token: <token-from-real-dispatch-or-mocked>" \
-H "X-Foir-Context: project=test;operation=summarize;execution_id=test;triggered_by=manual" \
-d '{
"operationKey": "summarize",
"trigger": { "type": "field", "fieldKey": "description", "fieldType": "content" },
"content": "Long text...",
"input": { "maxLength": 200 },
"record": null,
"context": { "projectId": "test", "tenantId": "test", "timestamp": "2026-04-28T12:00:00Z" }
}'You can also test via the Foir admin once registered — see Step 7.
Step 6: Deploy
Deploy your service to any host (Vercel, Railway, Render, Cloudflare Workers, Fly.io, Docker, etc.).
For the API:
cd my-summarizer/api
npm run build
PORT=3002 npm startFor the UI (if you have one):
cd my-summarizer/ui
npm run build
# Serve dist/ from any static hostThe deployed URL is what you’ll register in the next step.
Step 7: Register in Foir
You can declare the operation in foir.config.ts and push, or create it manually in the admin.
Via foir.config.ts (preferred)
import { defineConfig, defineOperation } from '@foir/cli/configs';
export default defineConfig({
key: 'my-summarizer',
name: 'My Summarizer',
operationBaseUrl: 'https://my-summarizer.vercel.app',
operations: [
defineOperation({
key: 'summarize',
name: 'AI Summarize',
endpoint: '/operations/summarize',
mode: 'sync',
timeoutMs: 30_000,
}),
defineOperation({
key: 'full-export',
name: 'Full Export',
endpoint: '/operations/full-export',
mode: 'async',
callbackTtlSeconds: 3600,
}),
],
});foir pushVia admin
- Go to Automation → Operations
- Click Create Operation
- Fill in:
| Field | Example Value |
|---|---|
| Key | summarize |
| Name | AI Summarize |
| Endpoint | https://my-summarizer.vercel.app/operations/summarize |
| Mode | sync |
| Touch Points | FIELD |
| Timeout | 30000 |
- Save and activate the operation.
Operation configuration options
| Field | Description |
|---|---|
| Key | Unique identifier (kebab-case). |
| Name | Display name. |
| Description | What this operation does. |
| Endpoint | Your service’s URL. |
| Mode | sync or async. |
| Touch Points | Where the operation can be triggered: FIELD, RECORD, API, LIFECYCLE. |
| Field Types | Field types this operation applies to (FIELD touch point). |
| Models | Models this operation applies to (RECORD touch point). |
| Timeout (ms) | Max execution time for the dispatch (sync only). |
| Capabilities | Capabilities the dispatch token carries — e.g. records:read:product, credentials:read. |
| Retry Policy | HTTP-dispatch retries (network/5xx). |
| Callback TTL | (async) How long the platform waits for a callback before timing out. |
| Callback Timeout Retry | (async) Retries when a callback times out, distinct from dispatch retries. |
| Input Schema | JSON Schema for validating user-provided input. |
| UI Config | Form fields shown to the user before execution. |
| Allowed Roles | Which user roles can trigger this operation. |
Step 8: Use the operation
In the admin UI
For field operations: open any record with the targeted field type, click the operations menu on the field, select your operation.
For record operations: open any record of the targeted model, click the operations menu in the toolbar, select your operation.
Via the public API
The public API is per-operation typed: an operation keyed summarize is exposed as executeSummarize. The mutation returns an Execute<Op>Payload envelope — execution is always present, and result carries the typed output (populated on SYNC, null on ASYNC). Requires the operations:execute scope.
mutation {
executeSummarize(input: { maxLength: 200 }, mode: SYNC) {
execution {
id
status
}
result {
summary
}
}
}There is no separate content argument — pass everything through the typed input. For async operations, read terminal state with operationExecution(id: $executionId) (scope operations:read) or subscribe to the per-operation operation<Op>Execution subscription — see Real-time and Subscriptions.
The Payload and Result Contract
OperationPayload (what your endpoint receives)
| Field | Description |
|---|---|
operationKey | The operation’s unique key. |
executionId | The execution row id (echo back on callbacks). |
trigger | How the operation was invoked (type, fieldKey, fieldType). |
content | The field value (field transforms) or null. |
input | User-provided form input. |
record | Full record context: id, modelKey, versionId, data, metadata. |
context | Platform context: projectId, tenantId, userId, locale, timestamp. |
credentials | App credentials, auto-injected by the platform. |
config | App config, auto-injected by the platform. |
callback | Async only — the callback token + GraphQL endpoint + per-op mutation names. |
OperationResult (sync only)
On success:
| Field | Description |
|---|---|
success: true | Indicates success. |
result | Result value (new field value for transforms, status data for record ops). |
metadata | Optional diagnostic info. |
On error:
| Field | Description |
|---|---|
success: false | Indicates failure. |
error.code | A short machine-readable code you choose (e.g. VALIDATION_ERROR, MISSING_INPUT, PROCESSING_FAILED). |
error.message | Human-readable error description. |
For async dispatches, you don’t return a result body — you return 202 Accepted and call cb.complete(result) / cb.fail(code, message) from your queue worker.
Tips
- Start with
foir create-config— it gives you a working project with token verification and Hono routing wired up. - Always verify
X-Foir-Tokenin production. Without verification, anyone could send fake requests to your endpoint. - 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.
- Use the bundled callback token instead of holding a long-lived
FOIR_API_KEYin your service. The capability set the operation declared is the capability set the callback token carries. - Return meaningful error codes. The platform displays the
error.messageto users and uses theerror.codefor retry decisions. - Keep operations focused. One operation should do one thing well. Create multiple operations rather than one that branches on input.
- Set appropriate timeouts. AI operations might need 30–60 seconds. Simple data sync might need 5 seconds. Match the timeout to your use case.
Next Steps
- Operations Concepts — How operations fit into the platform
- Operation Configuration — Full
defineOperationreference - Editor SDK Server API —
verifyScopedToken,createCallbackClient, and friends - Building an App — When you want to ship a reusable installable unit, not just a private operation