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-editorcustom-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 devThe 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:
$redirectplaceholders reference theredirectsink contract. They get resolved to a concrete project model key when the admin maps the sink.- Capabilities are precise:
records:read:$redirect, not barerecords:read. Bare reads/writes are rejected at install. run_after_install: trueondeploy-workermeans the platform auto-runs it once after install (and after__validate_credentialspasses).- 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 choiceDeploy 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 pushThen 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_credentialsfor any app where the validity of the credentials isn’t visible until the first real call. Surface the error early. - Use
run_after_install: truefor one-time setup operations. The admin doesn’t need to remember to click Run. - Use
replaces_default: trueonMAIN_EDITORplacements 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:$redirectand{ sinkContract: 'redirect' }resolve at install. They let one manifest install into many projects with different mapped model keys.
Next Steps
- Apps — Concept overview and lifecycle
- Defining Apps — Project-side
foir.config.tsdeclaration - Editor SDK Server API —
verifyScopedToken,createCallbackClient, and the rest of the middleware toolkit - Editor SDK Client API —
EditorProvider,useEditor, anduseAutoResizefor placements