Skip to Content
Config SystemDefining Apps

Defining Apps

Apps are pluggable units delivered by manifest URL. Declare which apps your project installs (and how they map onto your models) in the apps block of foir.config.ts. The CLI reconciles platform state against this declaration on every foir push.

For background on what apps are, see Apps.

App Interface

interface AppInput { /** https URL to the manifest JSON. */ source: string; /** Opaque settings forwarded to the app's middleware. Shape is driven by manifest's settings_schema. */ settings?: Record<string, unknown>; mappings?: { /** Mapping from manifest source-type names to project models. */ sources?: Record<string, AppSourceMappingInput>; /** Mapping from manifest sink-contract names to project models. */ sinks?: Record<string, AppSinkMappingInput>; /** Per-placement field choice for TARGET_FIELD placements with field_selected_at_install=true. */ placementFields?: Record<string, AppPlacementFieldChoiceInput>; }; } interface AppSourceMappingInput { /** Project model key the source type maps to. */ toModel: string; /** Field key on the model that's the natural-key for upserts. Must have naturalKey: true. */ naturalKey: string; /** Map from manifest field name → project model field key. */ fields: Record<string, string>; } interface AppSinkMappingInput { toModel: string; naturalKey: string; fields: Record<string, string>; } interface AppPlacementFieldChoiceInput { /** Resolved model key (or "*"). */ model: string; /** Project field key the placement attaches to. */ field: string; }

The top-level apps field is Record<string, AppInput> keyed by app name. The key under apps.<name> must match the manifest’s name field exactly.

Basic Example

import { defineConfig } from '@eide/foir-cli/configs'; export default defineConfig({ key: 'bobs-country-bunker', name: "Bob's Country Bunker", models: [ { key: 'product', name: 'Product', fields: [ { key: 'title', type: 'text', label: 'Title', required: true }, { key: 'handle', type: 'text', label: 'Handle', required: true, config: { naturalKey: true } }, { key: 'vendor', type: 'text', label: 'Vendor' }, { key: 'priceRange', type: 'json', label: 'Price range' }, { key: 'editorial', type: 'content', label: 'Editorial copy' }, ], }, ], apps: { shopify: { source: 'https://shopify.apps.foir.io/manifest.json', settings: { shop: 'bobscountrybunker.myshopify.com', apiVersion: '2025-10', }, mappings: { sources: { product: { toModel: 'product', naturalKey: 'handle', fields: { title: 'title', handle: 'handle', vendor: 'vendor', priceRange: 'priceRange', }, }, }, }, }, }, });
foir push

The CLI:

  1. Fetches the manifest from source
  2. Validates it
  3. Compares the supplied mappings to what the manifest declares
  4. Installs the app (or updates it, if already installed)
  5. Records the pushed mappings as a snapshot for three-way merge on the next push

After install, credentials still need to be written separatelyfoir.config.ts never carries secrets. For OAuth apps, click Authorize on the app’s detail page. For API-key apps, fill the credentials form on the same page.

Sources vs sinks

Source types describe inbound data the app can deliver to your project (Shopify products, Stripe customers, GitHub issues). Sink contracts describe outbound data the app needs to read from your project to push elsewhere (Cloudflare redirects, Algolia search docs).

The mapping shape is the same for both: which project model receives or supplies the data, what field is the natural key, and which app-side field name corresponds to which model field key.

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', activeFrom: 'activeFrom', activeTo: 'activeTo', }, }, }, }, }, },

Field-level placement choices

If the manifest declares a TARGET_FIELD placement with field_selected_at_install: true, the project must supply a field choice keyed by the placement key:

apps: { 'color-picker': { source: 'https://color-picker.foir.dev/manifest.json', mappings: { placementFields: { // Manifest declared a placement with key="picker" and // field_selected_at_install=true. picker: { model: 'product', field: 'accentColor', }, }, }, }, },

The same app can be installed in multiple projects with different field choices — that’s the whole point of deferring the choice to install time.

Multiple apps

Declare any number of apps. Each entry is independent:

apps: { shopify: { source: 'https://shopify.apps.foir.io/manifest.json', settings: { shop: 'bobscountrybunker.myshopify.com' }, mappings: { sources: { product: { /* ... */ } } }, }, redirector: { source: 'https://redirector.foir.dev/manifest.json', settings: { cloudflareZone: 'bobscountrybunker.com', workerName: 'foir-redirects-bcb' }, mappings: { sinks: { redirect: { /* ... */ } } }, }, 'color-picker': { source: 'https://color-picker.foir.dev/manifest.json', mappings: { placementFields: { picker: { model: 'product', field: 'accentColor' } }, }, }, },

Diff-aware push (three-way merge)

foir push records the pushed mappings payload as a snapshot on the platform. On the next push, the CLI does a three-way merge:

  • The base is what was last pushed
  • The theirs is what’s currently on the platform (which the admin may have edited via the UI)
  • The ours is what your foir.config.ts says now

Conflicts surface as errors — the CLI doesn’t silently clobber admin-side edits. Resolve by either editing your config to match, or pulling the admin state into your config (foir pull writes the current mappings back). This is what makes foir push safe even when admins also click around in the dashboard.

Pulling app state back

foir pull

…writes the live app mappings back into your foir.config.ts so committed code is the source of truth again. Use this whenever an admin has edited mappings via the UI and you want to bring those edits into version control.

Push order

Within a single push, the CLI:

  1. Pushes models, operations, hooks, schedules, segments, auth providers, placements, API keys (project-owned resources)
  2. Then pushes apps — install or update each app in declaration order

This ordering matters: the project’s models need to exist before app mappings can resolve. If your config references a model under apps.<name>.mappings.sources.X.toModel, that model must also be declared in the same config (or already exist on the platform).

Validation rules

The CLI rejects a push that would leave the platform in an invalid state:

  • Every declared source type and sink contract in the manifest must have a mapping
  • The target model must exist (declared in this config or already on the platform)
  • The target model must have every field referenced in fields
  • The natural-key field must have naturalKey: true
  • Optional source/sink fields can be left unmapped
  • Every placement with field_selected_at_install: true must have a placementFields entry

Errors surface with structured paths so you can fix them in your config:

push failed: app "shopify": mappings.sources.product.naturalKey "handle" references field "handle" which is not declared with naturalKey: true on model "product"

Next Steps

Last updated on