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 @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 '@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:

  1. Extracts the token from X-Foir-Token (missing → 401)
  2. Verifies the JWT signature against the cached JWKS (5-minute TTL), validating iss, exp, and nbf
  3. 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
  4. Returns the parsed claims, the tenantId/projectId/appName subject, and the X-Foir-Context correlation 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-Token and X-Foir-Context, and operation request templates cannot override them.
  • Pin expectedProjectId from 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_ms and 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 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 '@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

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)

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.codeA short machine-readable code you choose (e.g. VALIDATION_ERROR, MISSING_INPUT, PROCESSING_FAILED).
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