Skip to Content
GuidesBuilding Operations

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 OperationResult inline (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 workflow

The --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.example
cd my-summarizer npm install cp ui/.env.example ui/.env.local cp api/.env.example api/.env.local npm run dev

The 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:

  1. Extracts the token from X-Foir-Token
  2. Verifies the JWT signature against the cached JWKS (5-minute TTL)
  3. Validates iss, exp, and nbf
  4. 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 start

For the UI (if you have one):

cd my-summarizer/ui npm run build # Serve dist/ from any static host

The 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 push

Via admin

  1. Go to Automation → Operations
  2. Click Create Operation
  3. Fill in:
FieldExample Value
Keysummarize
NameAI Summarize
Endpointhttps://my-summarizer.vercel.app/operations/summarize
Modesync
Touch PointsFIELD
Timeout30000
  1. Save and activate the operation.

Operation configuration options

FieldDescription
KeyUnique identifier (kebab-case).
NameDisplay name.
DescriptionWhat this operation does.
EndpointYour service’s URL.
Modesync or async.
Touch PointsWhere the operation can be triggered: FIELD, RECORD, API, LIFECYCLE.
Field TypesField types this operation applies to (FIELD touch point).
ModelsModels this operation applies to (RECORD touch point).
Timeout (ms)Max execution time for the dispatch (sync only).
CapabilitiesCapabilities the dispatch token carries — e.g. records:read:product, credentials:read.
Retry PolicyHTTP-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 SchemaJSON Schema for validating user-provided input.
UI ConfigForm fields shown to the user before execution.
Allowed RolesWhich 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)

FieldDescription
operationKeyThe operation’s unique key.
executionIdThe execution row id (echo back on callbacks).
triggerHow the operation was invoked (type, fieldKey, fieldType).
contentThe field value (field transforms) or null.
inputUser-provided form input.
recordFull record context: id, modelKey, versionId, data, metadata.
contextPlatform context: projectId, tenantId, userId, locale, timestamp.
credentialsApp credentials, auto-injected by the platform.
configApp config, auto-injected by the platform.
callbackAsync only — the callback token + GraphQL endpoint + per-op mutation names.

OperationResult (sync only)

On success:

FieldDescription
success: trueIndicates success.
resultResult value (new field value for transforms, status data for record ops).
metadataOptional diagnostic info.

On error:

FieldDescription
success: falseIndicates failure.
error.codeOne of: VALIDATION_ERROR, TIMEOUT, UPSTREAM_ERROR, INTERNAL_ERROR, RATE_LIMITED, NOT_FOUND.
error.messageHuman-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-Token in 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_KEY in 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.message to users and uses the error.code for 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

Last updated on