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 @eide/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 '@eide/foir-editor-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 '@eide/foir-editor-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. Verify it against the platform JWKS endpoint before processing the request.
import { Hono } from 'hono';
import {
verifyScopedToken,
parseSubject,
parseContextHeader,
type ScopedTokenClaims,
} from '@eide/foir-editor-sdk/server';
const JWKS_URL = 'https://api.foir.io/.well-known/jwks.json';
const app = new Hono<{
Variables: { claims: ScopedTokenClaims; ctx: Record<string, string> };
}>();
async function verifyDispatch(c, next) {
const token = c.req.header('x-foir-token');
if (!token) return c.json({ error: 'missing token' }, 401);
try {
const claims = await verifyScopedToken(token, JWKS_URL);
c.set('claims', claims);
c.set('ctx', parseContextHeader(c.req.header('x-foir-context')));
await next();
} catch (err) {
return c.json({ error: 'invalid token', detail: String(err) }, 401);
}
}
app.post('/operations/*', verifyDispatch);The middleware:
- Extracts the token from
X-Foir-Token - Verifies the JWT signature against the cached JWKS (5-minute TTL)
- Validates
iss,exp, andnbf - Exposes the parsed claims and context header on the request
JWKS keys are cached automatically per isolate; you don’t need to manage that.
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 '@eide/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
mutation {
publicExecuteOperation(input: {
operationKey: "summarize"
input: { maxLength: 200 }
content: "Your long text content..."
mode: SYNC
}) {
success
result
executionId
error { code message }
}
}For async operations, poll publicOperationExecution(id: $executionId) or subscribe to the jobs:{executionId} channel — 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 | One of: VALIDATION_ERROR, TIMEOUT, UPSTREAM_ERROR, INTERNAL_ERROR, RATE_LIMITED, NOT_FOUND. |
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