Skip to Content
GuidesBuilding an App

Building an App

This guide walks you through building a Foir app from scratch — manifest, middleware, a custom UI placement, an operation, and an inbound webhook. By the end, you’ll have an installable app that any Foir project can adopt with one URL.

If you only need a private operation for one project (no reuse, no sharing), see Building Operations instead. This guide is for when you want a manifest-installable, reusable unit.

What You’ll Build

A “redirector” app that:

  • Defines a sink contract for redirect rules
  • Hosts a Cloudflare Worker that consumes redirect records and writes them to KV
  • Provides a custom main-editor placement for authoring redirects
  • Exposes operations: redirect-sync, deploy-worker, __uninstall

We’ll use redirector as the running example — same pattern works for Shopify-style inbound apps, Algolia-style outbound apps, DeepL-style sidecar apps, etc.

Step 1: Scaffold the project

npx @eide/foir-cli create-config redirector --type custom-editor

custom-editor gives you a UI app + middleware API. For purely backend apps, use --type workflow. For embeddable static iframes (color picker), use --type widget.

Project structure:

redirector/ package.json manifest.json # The app manifest ui/ # Vite + React + Tailwind iframe src/ App.tsx api/ # Hono middleware src/ index.ts routes/ operations.ts # Operation endpoints webhooks.ts # Inbound webhook handlers authorize.ts # OAuth flow (if applicable)
cd redirector npm install npm run dev

The UI runs on http://localhost:3001, the API on http://localhost:3002.

Step 2: Write the manifest

manifest.json describes what your app contributes. Required fields are name and version; everything else is whatever your app actually needs.

{ "name": "redirector", "version": "1.0.0", "metadata": { "displayName": "Cloudflare Redirector", "description": "Sync redirect records to Cloudflare KV via a Worker.", "icon": "redirect", "author": "Bob's Country Bunker" }, "middleware": { "url": "https://redirector.foir.dev" }, "credentials": { "strategy": "API_KEY", "schema": { "type": "object", "properties": { "cloudflareApiToken": { "type": "string", "title": "Cloudflare API token" }, "cloudflareAccountId": { "type": "string", "title": "Cloudflare account id" } }, "required": ["cloudflareApiToken", "cloudflareAccountId"] } }, "settings_schema": { "type": "object", "properties": { "cloudflareZone": { "type": "string", "title": "Zone (e.g. example.com)" }, "workerName": { "type": "string", "title": "Worker name" } }, "required": ["cloudflareZone", "workerName"] }, "sinks": { "redirect": { "label": "Redirect", "natural_key": "source", "fields": { "source": { "type": "text", "semantic": "source-url", "required": true }, "target": { "type": "text", "semantic": "target-url", "required": true }, "status": { "type": "select", "semantic": "status-code", "required": true }, "priority": { "type": "number", "semantic": "priority", "required": false } } } }, "operations": [ { "key": "redirect-sync", "name": "Sync redirects to edge", "endpoint": "/operations/redirect-sync", "capabilities": ["credentials:read", "records:read:$redirect", "status:write"] }, { "key": "deploy-worker", "name": "Deploy Cloudflare Worker", "endpoint": "/operations/deploy-worker", "capabilities": ["credentials:read", "status:write"], "run_after_install": true }, { "key": "__validate_credentials", "name": "Validate Cloudflare credentials", "endpoint": "/operations/validate-credentials", "capabilities": ["credentials:read"] }, { "key": "__uninstall", "name": "Tear down Cloudflare resources", "endpoint": "/operations/uninstall", "capabilities": ["credentials:read", "status:write"] } ], "hooks": [ { "key": "sync-on-create", "name": "Sync on redirect create", "event": "RECORD_CREATED", "filter": { "sinkContract": "redirect" }, "operation": "redirect-sync" }, { "key": "sync-on-update", "name": "Sync on redirect update", "event": "RECORD_UPDATED", "filter": { "sinkContract": "redirect" }, "operation": "redirect-sync" }, { "key": "sync-on-delete", "name": "Sync on redirect delete", "event": "RECORD_DELETED", "filter": { "sinkContract": "redirect" }, "operation": "redirect-sync" } ], "ui": { "placements": [ { "key": "redirect-editor", "target": "MAIN_EDITOR", "model": "$redirect", "tab": "edit", "title": "Redirect editor", "url": "https://redirector.foir.dev/editor", "replaces_default": true } ] } }

A few things to call out:

  • $redirect placeholders reference the redirect sink contract. They get resolved to a concrete project model key when the admin maps the sink.
  • Capabilities are precise: records:read:$redirect, not bare records:read. Bare reads/writes are rejected at install.
  • run_after_install: true on deploy-worker means the platform auto-runs it once after install (and after __validate_credentials passes).
  • Reserved operation keys (__validate_credentials, __uninstall) start with __. The platform calls them at specific lifecycle moments.

Step 3: Implement operations

api/src/routes/operations.ts already includes the dispatch-token middleware from the scaffold. Wire your handlers:

import { Hono } from 'hono'; import { verifyScopedToken, parseSubject, parseContextHeader, type OperationPayload, type OperationResult, 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); // __validate_credentials — called before submitted credentials are persisted. // The submitted values are in payload.input; payload.credentials is empty // because nothing's been written yet. app.post('/operations/validate-credentials', async (c) => { const { input } = (await c.req.json()) as OperationPayload; const { cloudflareApiToken, cloudflareAccountId } = input as { cloudflareApiToken: string; cloudflareAccountId: string; }; const ok = await pingCloudflare(cloudflareApiToken, cloudflareAccountId); return c.json<OperationResult>({ success: ok, error: ok ? undefined : { code: 'INVALID_CREDENTIALS', message: 'Cloudflare rejected the token' }, }); }); // deploy-worker — runs once at install time. app.post('/operations/deploy-worker', async (c) => { const payload = (await c.req.json()) as OperationPayload; const { cloudflareApiToken } = payload.credentials as { cloudflareApiToken: string }; const { cloudflareZone, workerName } = payload.config as { cloudflareZone: string; workerName: string; }; const workerUrl = await deployWorker(cloudflareApiToken, cloudflareZone, workerName); return c.json<OperationResult>({ success: true, result: { workerUrl }, }); }); // redirect-sync — fired by hooks on every redirect record change. app.post('/operations/redirect-sync', async (c) => { const claims = c.get('claims'); const payload = (await c.req.json()) as OperationPayload; const { projectId } = parseSubject(claims.sub); const { cloudflareApiToken } = payload.credentials as { cloudflareApiToken: string }; const { workerName } = payload.config as { workerName: string }; // Fetch all redirects from the platform using our scoped token's read cap const records = await fetchAllRedirects(payload.callback?.token ?? c.req.header('x-foir-token')!, projectId); // Push to Cloudflare KV const count = await syncToKv(cloudflareApiToken, workerName, records); return c.json<OperationResult>({ success: true, result: { redirectCount: count }, }); }); // __uninstall — admin removes the app; we tear down our Cloudflare resources. app.post('/operations/uninstall', async (c) => { const payload = (await c.req.json()) as OperationPayload; const { cloudflareApiToken } = payload.credentials as { cloudflareApiToken: string }; const { workerName } = payload.config as { workerName: string }; await tearDownWorker(cloudflareApiToken, workerName); return c.json<OperationResult>({ success: true }); });

The token’s capabilities are exactly what the manifest declared — records:read:redirect (after placeholder resolution), credentials:read, status:write. Use them via recordsReadModels(claims) if you want to enforce per-model boundaries inside your handler.

Step 4: Build the UI placement

ui/src/App.tsx is your iframe app. The Editor SDK’s EditorProvider handles the postMessage handshake with the host:

import { useEffect, useState } from 'react'; import { EditorProvider, useEditor, useAutoResize } from '@eide/foir-editor-sdk'; function RedirectForm() { const { isReady, init, updateField, requestOperation, setDirty } = useEditor(); const containerRef = useAutoResize({ minHeight: 600 }); const [synced, setSynced] = useState<{ redirectCount: number } | null>(null); if (!isReady || !init) return null; return ( <div ref={containerRef} className="p-6 space-y-4"> <input defaultValue={(init.values.source as string) ?? ''} onChange={(e) => { updateField('source', e.target.value); setDirty(true); }} placeholder="Source URL" /> <input defaultValue={(init.values.target as string) ?? ''} onChange={(e) => { updateField('target', e.target.value); setDirty(true); }} placeholder="Target URL" /> <button onClick={async () => { const result = await requestOperation('redirect-sync', {}); if (result.success) setSynced(result.result as { redirectCount: number }); }} > Sync now </button> {synced && <p>Synced {synced.redirectCount} redirects</p>} </div> ); } export function App() { return ( <EditorProvider parentOrigin="https://app.foir.dev"> <RedirectForm /> </EditorProvider> ); }

For sidebar placements, use configMode === 'sidebar' and the selectedFieldKeys / localeContext extras — see Editor SDK Examples.

Step 5: Deploy

Deploy the API to whatever you like — Cloudflare Workers, Render, Fly, Vercel. The middleware needs to be reachable at the URL declared in your manifest’s middleware.url.

cd redirector/api npm run build # Deploy with your platform of choice

Deploy the UI as static assets (Cloudflare Pages, Netlify, Vercel, S3+CloudFront). The URL needs to match your manifest’s placement url.

Host manifest.json somewhere reachable (S3, Cloudflare Pages, your own domain). One file, no build pipeline required.

Set FOIR_SIGNING_SECRET in your API’s environment — the Foir CLI will publish it to your service when a project installs the app.

Step 6: Test the install

From a Foir project, install the app:

foir apps install https://redirector.foir.dev/manifest.json \ --sink-map '{"redirect":{"toModel":"redirect","naturalKey":"sourcePattern","fields":{"source":"sourcePattern","target":"targetPattern","status":"statusCode","priority":"priority"}}}'

Or from foir.config.ts:

apps: { redirector: { source: 'https://redirector.foir.dev/manifest.json', settings: { cloudflareZone: 'bobscountrybunker.com', workerName: 'foir-redirects-bcb' }, mappings: { sinks: { redirect: { toModel: 'redirect', naturalKey: 'sourcePattern', fields: { source: 'sourcePattern', target: 'targetPattern', status: 'statusCode', priority: 'priority', }, }, }, }, }, },
foir push

Then write the credentials from the app’s detail page in the admin. Once credentials land, __validate_credentials runs first; if it returns success, deploy-worker runs next (because of run_after_install: true). Both calls hit your middleware with a scoped token in X-Foir-Token.

After that, every record write on the mapped redirect model fires redirect-sync via the hooks.

Step 7: Update flow

Bump your manifest’s version and adjust whatever you need (new operation, new field, new placement). Projects that have your app installed run:

foir apps update redirector --dry-run

…to see the diff classified into safe-auto / requires-confirmation / rejected changes. Without --dry-run it applies the safe-auto changes immediately and surfaces the rest for admin review.

The platform never silently re-fetches your manifest. Every update is explicit.

Step 8: Publish

Apps don’t go through an npm publish or platform registry submission. Sharing the URL is the publish step:

  • Add the manifest URL to your README
  • Submit it to the Foir app catalog (when one exists)
  • Share with specific projects

That’s it.

Tips

  • Keep the manifest minimal. If you don’t need credentials, omit them. If you don’t need middleware, omit it. Color-picker apps are six lines of JSON.
  • Use __validate_credentials for any app where the validity of the credentials isn’t visible until the first real call. Surface the error early.
  • Use run_after_install: true for one-time setup operations. The admin doesn’t need to remember to click Run.
  • Use replaces_default: true on MAIN_EDITOR placements when your custom editor fully owns the record-authoring surface (Redirector). Skip it for tabs that augment the default editor (Shopify “Open in admin” deep-link).
  • Use sinks for outbound, sources for inbound. Redirector is a sink: project owns the data, app pushes it out. Shopify is a source: external owns the data, app brings it in.
  • Use placeholders for capabilities and filters. records:read:$redirect and { sinkContract: 'redirect' } resolve at install. They let one manifest install into many projects with different mapped model keys.

Next Steps

Last updated on